• 6 min read

MySQL 9.7 Cascade Triggers: Make Foreign Key Actions Fire Your Triggers

MySQL 9.7 adds enable_cascade_triggers so ON DELETE CASCADE and SET NULL can fire child-table triggers. Learn how it works and what to test in PHP apps.

Featured image for "MySQL 9.7 Cascade Triggers: Make Foreign Key Actions Fire Your Triggers"

If you have ever put an audit trigger on a child table and then wondered why it stayed silent when a parent row was deleted, you were hitting a long-standing quirk of how MySQL handled foreign key cascades. MySQL 9.7 adds a switch that changes this: a new system variable called enable_cascade_triggers.

It is a small line in the release notes, but it matters for anyone who relies on triggers for audit logs, counters, or search index bookkeeping. In this post we will look at what the release notes actually say, a small PHP and Laravel example, and the sharp edges you should test before turning it on. I tested nothing against a live server for this article, so treat the SQL as a sketch and run it on a disposable database first.

What the release notes say

The MySQL 9.7.0 release notes (dated 2026-04-21) list this under “Functionality Added or Changed”:

Foreign key CASCADE and SET NULL actions performed by SQL-layer foreign key handling can now activate BEFORE and AFTER triggers on the affected child rows. You enable it for a session by setting enable_cascade_triggers to ON. The default is OFF.

A few more details from the same note are worth reading twice:

  • Cascade-induced triggers run as part of the initiating statement.
  • If a trigger throws an error, the statement and all of its cascaded changes are rolled back.
  • With row-based replication, the changes made by the triggers are recorded and are not re-executed on replicas.
  • With statement-based replication, replicas use the source’s enable_cascade_triggers setting.

The “can now” wording tells us that cascaded changes did not fire child-table triggers before. The default stays OFF, so nothing changes for you until you opt in. The notes do not say whether the variable is global, session, or both beyond mentioning the session case, so check the InnoDB parameters reference for the exact scope on your build.

A concrete example

Say PHP Architect stores subscriber records and their issue downloads. When a subscriber is deleted, the downloads go with them through a cascade, and we want an audit row for each deleted download.

CREATE TABLE subscribers (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    email VARCHAR(255) NOT NULL
) ENGINE=InnoDB;

CREATE TABLE downloads (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    subscriber_id BIGINT UNSIGNED NOT NULL,
    issue_slug VARCHAR(100) NOT NULL,
    CONSTRAINT fk_downloads_subscriber
        FOREIGN KEY (subscriber_id) REFERENCES subscribers (id)
        ON DELETE CASCADE
) ENGINE=InnoDB;

CREATE TABLE download_audit (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    download_id BIGINT UNSIGNED NOT NULL,
    issue_slug VARCHAR(100) NOT NULL,
    removed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

DELIMITER //
CREATE TRIGGER downloads_after_delete
AFTER DELETE ON downloads
FOR EACH ROW
BEGIN
    INSERT INTO download_audit (download_id, issue_slug)
    VALUES (OLD.id, OLD.issue_slug);
END//
DELIMITER ;

Deleting a subscriber directly deletes the subscriber’s downloads through the cascade. With the default setting, you should expect no audit rows from the trigger on the cascaded deletes, based on the release note wording. Now flip the switch for the session:

SET SESSION enable_cascade_triggers = ON;

DELETE FROM subscribers WHERE id = 42;

SELECT * FROM download_audit;

With the variable on, the AFTER DELETE trigger on downloads should fire for each cascaded child row, and the audit table fills up. Verify this on your own server, because the behavior depends on the build and your trigger definitions.

Using it from PHP

The variable is session scoped in the example, so the natural place to set it is right after you open a connection or inside the unit of work that needs it. With plain PDO:

<?php

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);

$pdo->exec('SET SESSION enable_cascade_triggers = ON');

$pdo->beginTransaction();

try {
    $stmt = $pdo->prepare('DELETE FROM subscribers WHERE id = :id');
    $stmt->execute(['id' => $subscriberId]);

    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

In Laravel, you can set it through the options or run it as an init command in your database connection config. The MYSQL_ATTR_INIT_COMMAND PDO option runs a statement on connect:

// config/database.php
'mysql' => [
    // ...
    'options' => extension_loaded('pdo_mysql') ? array_filter([
        PDO::MYSQL_ATTR_INIT_COMMAND => 'SET SESSION enable_cascade_triggers = ON',
    ]) : [],
],

Do not enable this globally on an app you have not audited. Turning it on for every connection is a behavior change for every cascade in your schema, including ones you forgot about.

Things to test before you enable it

Trigger errors now abort cascades. The release notes are clear that an error in a cascade-induced trigger rolls back the statement and its cascaded changes. A buggy trigger that used to be harmless, because it never ran on cascades, can now make a delete fail. Wrap deletes in transactions and handle the exception, as in the PDO example above.

Performance grows with cascade size. A trigger that fires once per child row on a parent with 100,000 children is 100,000 trigger executions inside one statement. Keep trigger bodies short. If you use triggers for audit logging at that scale, consider whether a bulk INSERT ... SELECT before the delete is cheaper.

Replication needs a check. Row-based replication records what the triggers did and does not re-run them, so replicas see the results. Statement-based replication follows the source’s setting. If you run mixed topologies, test that your replicas end up with matching audit tables.

Eloquent events are a different layer. Laravel model events like deleting and deleted only fire when you delete through Eloquent models, and they do not see database-level cascades at all. If you currently emulate cascade side effects with model observers, enabling database cascade triggers might double up the work. Pick one layer for each side effect.

A short note on invisible primary keys

The same release notes include a fix that is useful if you run sql_generate_invisible_primary_key=ON. Previously, Generated Invisible Primary Keys were added to tables even when a UNIQUE NOT NULL key already existed, and creation failed if that unique key was auto-incremented. As of 9.7.0, UNIQUE NOT NULL keys are treated as primary key equivalents for CREATE and ALTER statements. A related fix lets you create tables with a primary key equivalent when sql_require_primary_key is enabled. If you ran into confusing errors when applying Laravel migrations on a server with those settings, this is worth a retest.

Should you turn it on?

Probably not everywhere. The feature is most useful in a narrow case: you already have child-table triggers that you expected to fire on cascades, or you are migrating logic out of application code into the database and want parity. For everything else, the default of OFF keeps existing behavior, which is the safe choice for a long-term support release.

Also keep in mind that 9.7 is an LTS line, and the release notes list later point releases through 9.7.4, dated 2026-10-20. Check those notes for anything affecting triggers or foreign keys before you roll out.

My suggested path is simple. Enable it in a staging session, run your delete-heavy test paths, watch the trigger error log and the timing, and only then decide whether to set it per connection. Whatever you pick, document it in your repo, because the setting changes what a plain DELETE does, and the next developer will not guess that from the schema.

Sources