From 2ae010421c6d4f08d80a7478856368af2ef606e0 Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:27:37 +0200 Subject: [PATCH 1/2] feat: add transaction lifecycle callbacks - Add afterCommit() and afterRollback() callbacks to database connections - Run callbacks only after the outermost transaction commits or rolls back - Discard callbacks registered for the opposite transaction outcome - Document callback exception behavior and automatic rollback handling - Add live database coverage for callback ordering, nesting, and failures Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/BaseConnection.php | 108 ++++++- .../Live/TransactionCallbacksTest.php | 263 ++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 1 + .../source/database/transactions.rst | 32 +++ .../source/database/transactions/010.php | 14 + 5 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 tests/system/Database/Live/TransactionCallbacksTest.php create mode 100644 user_guide_src/source/database/transactions/010.php diff --git a/system/Database/BaseConnection.php b/system/Database/BaseConnection.php index 0d167c2b92cc..b856017cd858 100644 --- a/system/Database/BaseConnection.php +++ b/system/Database/BaseConnection.php @@ -358,6 +358,20 @@ abstract class BaseConnection implements ConnectionInterface */ protected bool $transException = false; + /** + * Callbacks to run after the outermost transaction commits. + * + * @var list + */ + protected array $transCommitCallbacks = []; + + /** + * Callbacks to run after the outermost transaction rolls back. + * + * @var list + */ + protected array $transRollbackCallbacks = []; + /** * Array of table aliases. * @@ -985,13 +999,15 @@ public function transComplete(): bool // The query() function will set this flag to FALSE in the event that a query failed if ($this->transStatus === false || $this->transFailure === true) { - $this->transRollback(); - - // If we are NOT running in strict mode, we will reset - // the _trans_status flag so that subsequent groups of - // transactions will be permitted. - if ($this->transStrict === false) { - $this->transStatus = true; + try { + $this->transRollback(); + } finally { + // If we are NOT running in strict mode, we will reset + // the _trans_status flag so that subsequent groups of + // transactions will be permitted. + if ($this->transStrict === false) { + $this->transStatus = true; + } } return false; @@ -1008,6 +1024,48 @@ public function transStatus(): bool return $this->transStatus; } + /** + * Register a callback to run after the outermost transaction commits. + * + * If no transaction is active, the callback runs immediately. + * + * @param callable(): void $callback + * + * @return $this + */ + public function afterCommit(callable $callback): static + { + if ($this->transDepth === 0) { + $callback(); + + return $this; + } + + $this->transCommitCallbacks[] = $callback; + + return $this; + } + + /** + * Register a callback to run after the outermost transaction rolls back. + * + * If no transaction is active, the callback is not run. + * + * @param callable(): void $callback + * + * @return $this + */ + public function afterRollback(callable $callback): static + { + if ($this->transDepth === 0) { + return $this; + } + + $this->transRollbackCallbacks[] = $callback; + + return $this; + } + /** * Begin Transaction */ @@ -1055,6 +1113,11 @@ public function transCommit(): bool if ($this->transDepth > 1 || $this->_transCommit()) { $this->transDepth--; + if ($this->transDepth === 0) { + $this->transRollbackCallbacks = []; + $this->runTransCommitCallbacks(); + } + return true; } @@ -1074,6 +1137,11 @@ public function transRollback(): bool if ($this->transDepth > 1 || $this->_transRollback()) { $this->transDepth--; + if ($this->transDepth === 0) { + $this->transCommitCallbacks = []; + $this->runTransRollbackCallbacks(); + } + return true; } @@ -1102,6 +1170,32 @@ public function handleTransStatus(): void } } + /** + * Run and clear callbacks registered for a successful transaction commit. + */ + protected function runTransCommitCallbacks(): void + { + $callbacks = $this->transCommitCallbacks; + $this->transCommitCallbacks = []; + + foreach ($callbacks as $callback) { + $callback(); + } + } + + /** + * Run and clear callbacks registered for a transaction rollback. + */ + protected function runTransRollbackCallbacks(): void + { + $callbacks = $this->transRollbackCallbacks; + $this->transRollbackCallbacks = []; + + foreach ($callbacks as $callback) { + $callback(); + } + } + /** * Begin Transaction */ diff --git a/tests/system/Database/Live/TransactionCallbacksTest.php b/tests/system/Database/Live/TransactionCallbacksTest.php new file mode 100644 index 000000000000..aa5e58dc33b1 --- /dev/null +++ b/tests/system/Database/Live/TransactionCallbacksTest.php @@ -0,0 +1,263 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Database\Live; + +use CodeIgniter\Database\Exceptions\DatabaseException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\DatabaseTestTrait; +use Config\Database; +use PHPUnit\Framework\Attributes\Group; +use RuntimeException; +use Tests\Support\Database\Seeds\CITestSeeder; + +/** + * @internal + * + * @no-final + */ +#[Group('DatabaseLive')] +class TransactionCallbacksTest extends CIUnitTestCase +{ + use DatabaseTestTrait; + + protected $refresh = true; + protected $seed = CITestSeeder::class; + + protected function setUp(): void + { + // Reset connection instance. + $this->db = Database::connect($this->DBGroup, false); + + parent::setUp(); + } + + public function testAfterCommitRunsImmediatelyWhenNoTransactionIsActive(): void + { + $callbacks = []; + + $result = $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'ran'; + }); + + $this->assertSame($this->db, $result); + $this->assertSame(['ran'], $callbacks); + } + + public function testAfterCommitRunsAfterSuccessfulTransactionCommit(): void + { + $callbacks = []; + + $this->db->transStart(); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'committed'; + }); + + $this->assertSame([], $callbacks); + + $this->db->transComplete(); + + $this->assertSame(['committed'], $callbacks); + } + + public function testAfterCommitDoesNotRunAfterTransactionRollsBack(): void + { + $callbacks = []; + + $this->db->transStart(true); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'committed'; + }); + + $this->db->transComplete(); + + $this->assertSame([], $callbacks); + } + + public function testAfterCommitRunsAfterOutermostTransactionCommit(): void + { + $callbacks = []; + + $this->db->transStart(); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'outer'; + }); + + $this->db->transStart(); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'inner'; + }); + $this->db->transComplete(); + + $this->assertSame([], $callbacks); + + $this->db->transComplete(); + + $this->assertSame(['outer', 'inner'], $callbacks); + } + + public function testAfterCommitCallbackExceptionBubblesAfterTransactionCommit(): void + { + $builder = $this->db->table('job'); + + $this->db->transStart(); + $builder->insert([ + 'name' => 'Committed Job', + 'description' => 'The transaction should still commit.', + ]); + $this->db->afterCommit(static function (): void { + throw new RuntimeException('Commit callback failed.'); + }); + + try { + $this->db->transComplete(); + $this->fail('Expected commit callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Commit callback failed.', $e->getMessage()); + } + + $this->seeInDatabase('job', ['name' => 'Committed Job']); + } + + public function testAfterRollbackDoesNotRunWhenNoTransactionIsActive(): void + { + $callbacks = []; + + $result = $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + + $this->assertSame($this->db, $result); + $this->assertSame([], $callbacks); + } + + public function testAfterRollbackRunsAfterTransactionRollsBack(): void + { + $callbacks = []; + + $this->db->transStart(true); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + + $this->assertSame([], $callbacks); + + $this->db->transComplete(); + + $this->assertSame(['rolled back'], $callbacks); + } + + public function testAfterRollbackDoesNotRunAfterSuccessfulTransactionCommit(): void + { + $callbacks = []; + + $this->db->transStart(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + + $this->db->transComplete(); + + $this->assertSame([], $callbacks); + } + + public function testAfterRollbackRunsAfterOutermostTransactionRollsBack(): void + { + $callbacks = []; + + $this->db->transStart(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'outer'; + }); + + $this->db->transStart(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'inner'; + }); + $this->db->transComplete(); + + $this->assertSame([], $callbacks); + + $this->db->transRollback(); + + $this->assertSame(['outer', 'inner'], $callbacks); + } + + public function testAfterRollbackCallbackExceptionBubblesAfterTransactionRollback(): void + { + $builder = $this->db->table('job'); + + $this->db->transStart(true); + $builder->insert([ + 'name' => 'Rolled Back Job', + 'description' => 'The transaction should still roll back.', + ]); + $this->db->afterRollback(static function (): void { + throw new RuntimeException('Rollback callback failed.'); + }); + + try { + $this->db->transComplete(); + $this->fail('Expected rollback callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Rollback callback failed.', $e->getMessage()); + } + + $this->dontSeeInDatabase('job', ['name' => 'Rolled Back Job']); + } + + public function testAfterRollbackCallbackExceptionDoesNotPreventNonStrictStatusReset(): void + { + $this->db->transStrict(false); + $this->db->transStart(true); + $this->db->afterRollback(static function (): void { + throw new RuntimeException('Rollback callback failed.'); + }); + + try { + $this->db->transComplete(); + $this->fail('Expected rollback callback exception.'); + } catch (RuntimeException $e) { + $this->assertSame('Rollback callback failed.', $e->getMessage()); + } + + $this->assertTrue($this->db->transStatus()); + } + + public function testAfterRollbackRunsAfterAutomaticRollbackOnQueryFailure(): void + { + $callbacks = []; + $builder = $this->db->transException(true)->table('job'); + + try { + $this->db->transStart(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + $builder->insert([ + 'name' => 'Rolled Back Job', + 'description' => 'The transaction should roll back.', + ]); + $builder->insert([ + 'id' => 1, + 'name' => 'Duplicate Job', + 'description' => 'This should fail.', + ]); + } catch (DatabaseException) { + // The framework already rolled back while handling the query failure. + } + + $this->assertSame(['rolled back'], $callbacks); + $this->dontSeeInDatabase('job', ['name' => 'Rolled Back Job']); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index af6ddecb3e94..41e8f7a4ecfa 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -195,6 +195,7 @@ Testing Database ======== +- Added ``afterCommit()`` and ``afterRollback()`` transaction callbacks to database connections. These callbacks run after the outermost transaction commits or rolls back. - Added ``trustServerCertificate`` option to ``SQLSRV`` database connections in ``Config\Database``. Set it to ``true`` to trust the server certificate without CA validation when using encrypted connections. Query Builder diff --git a/user_guide_src/source/database/transactions.rst b/user_guide_src/source/database/transactions.rst index 5c48f2806240..5ab42c7e559a 100644 --- a/user_guide_src/source/database/transactions.rst +++ b/user_guide_src/source/database/transactions.rst @@ -111,6 +111,38 @@ If you want an exception to be thrown when a query error occurs, you can use If a query error occurs, all the queries will be rolled backed, and a ``DatabaseException`` will be thrown. +Running Code after Commit or Rollback +===================================== + +.. versionadded:: 4.8.0 + +You may register callbacks to run only after the outermost transaction has +successfully committed by using the ``afterCommit()`` method, or after the +outermost transaction has rolled back by using the ``afterRollback()`` method: + +.. literalinclude:: transactions/010.php + +Callbacks registered during an active transaction are delayed until the +outermost transaction commits or rolls back. + +If the transaction commits, ``afterCommit()`` callbacks run and +``afterRollback()`` callbacks are discarded. If the transaction rolls back, +``afterRollback()`` callbacks run and ``afterCommit()`` callbacks are discarded. +If no transaction is active, ``afterCommit()`` callbacks run immediately, while +``afterRollback()`` callbacks are not run. + +This is useful for side effects that should only happen after committed data is +visible, such as dispatching a queued job or sending a notification, and for +cleanup that should only happen after a real rollback. + +Callbacks run after the database transaction has already committed or rolled +back. If a callback throws an exception, that exception bubbles to the caller, +but the transaction outcome is not changed. + +Rollback callbacks also run when CodeIgniter automatically rolls back an active +transaction while handling a transaction failure or cleaning up an unfinished +transaction. + Disabling Transactions ====================== diff --git a/user_guide_src/source/database/transactions/010.php b/user_guide_src/source/database/transactions/010.php new file mode 100644 index 000000000000..84f0ae2959b6 --- /dev/null +++ b/user_guide_src/source/database/transactions/010.php @@ -0,0 +1,14 @@ +db->transStart(); +$this->db->query('AN SQL QUERY...'); + +$this->db->afterCommit(static function (): void { + // Dispatch a queued job or run another side effect after commit. +}); + +$this->db->afterRollback(static function (): void { + // Run cleanup that should only happen after rollback. +}); + +$this->db->transComplete(); From f3181b2acbee47f5f307c165919dd7a4ad33da5c Mon Sep 17 00:00:00 2001 From: memleakd <121398829+memleakd@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:43:58 +0200 Subject: [PATCH 2/2] feat(database): refine transaction callback API Signed-off-by: memleakd <121398829+memleakd@users.noreply.github.com> --- system/Database/ConnectionInterface.php | 18 +++++++++++ .../Live/TransactionCallbacksTest.php | 32 +++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 2 +- .../source/database/transactions.rst | 16 ++++++++++ .../source/database/transactions/011.php | 5 +++ 5 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 user_guide_src/source/database/transactions/011.php diff --git a/system/Database/ConnectionInterface.php b/system/Database/ConnectionInterface.php index 3c43b173fc4e..f5233d79e864 100644 --- a/system/Database/ConnectionInterface.php +++ b/system/Database/ConnectionInterface.php @@ -115,6 +115,24 @@ public function query(string $sql, $binds = null); */ public function simpleQuery(string $sql); + /** + * Register a callback to run after the outermost transaction commits. + * + * @param callable(): void $callback + * + * @return $this + */ + public function afterCommit(callable $callback): static; + + /** + * Register a callback to run after the outermost transaction rolls back. + * + * @param callable(): void $callback + * + * @return $this + */ + public function afterRollback(callable $callback): static; + /** * Returns an instance of the query builder for this connection. * diff --git a/tests/system/Database/Live/TransactionCallbacksTest.php b/tests/system/Database/Live/TransactionCallbacksTest.php index aa5e58dc33b1..a295d4c86b74 100644 --- a/tests/system/Database/Live/TransactionCallbacksTest.php +++ b/tests/system/Database/Live/TransactionCallbacksTest.php @@ -70,6 +70,22 @@ public function testAfterCommitRunsAfterSuccessfulTransactionCommit(): void $this->assertSame(['committed'], $callbacks); } + public function testAfterCommitRunsAfterManualTransactionCommit(): void + { + $callbacks = []; + + $this->db->transBegin(); + $this->db->afterCommit(static function () use (&$callbacks): void { + $callbacks[] = 'committed'; + }); + + $this->assertSame([], $callbacks); + + $this->db->transCommit(); + + $this->assertSame(['committed'], $callbacks); + } + public function testAfterCommitDoesNotRunAfterTransactionRollsBack(): void { $callbacks = []; @@ -157,6 +173,22 @@ public function testAfterRollbackRunsAfterTransactionRollsBack(): void $this->assertSame(['rolled back'], $callbacks); } + public function testAfterRollbackRunsAfterManualTransactionRollback(): void + { + $callbacks = []; + + $this->db->transBegin(); + $this->db->afterRollback(static function () use (&$callbacks): void { + $callbacks[] = 'rolled back'; + }); + + $this->assertSame([], $callbacks); + + $this->db->transRollback(); + + $this->assertSame(['rolled back'], $callbacks); + } + public function testAfterRollbackDoesNotRunAfterSuccessfulTransactionCommit(): void { $callbacks = []; diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 41e8f7a4ecfa..277ff3043261 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -195,7 +195,7 @@ Testing Database ======== -- Added ``afterCommit()`` and ``afterRollback()`` transaction callbacks to database connections. These callbacks run after the outermost transaction commits or rolls back. +- Added ``afterCommit()`` and ``afterRollback()`` transaction callbacks to database connections. These callbacks run after the outermost transaction commits or rolls back. See :ref:`transactions-transaction-callbacks`. - Added ``trustServerCertificate`` option to ``SQLSRV`` database connections in ``Config\Database``. Set it to ``true`` to trust the server certificate without CA validation when using encrypted connections. Query Builder diff --git a/user_guide_src/source/database/transactions.rst b/user_guide_src/source/database/transactions.rst index 5ab42c7e559a..77a188d1edd3 100644 --- a/user_guide_src/source/database/transactions.rst +++ b/user_guide_src/source/database/transactions.rst @@ -111,6 +111,8 @@ If you want an exception to be thrown when a query error occurs, you can use If a query error occurs, all the queries will be rolled backed, and a ``DatabaseException`` will be thrown. +.. _transactions-transaction-callbacks: + Running Code after Commit or Rollback ===================================== @@ -131,6 +133,16 @@ If the transaction commits, ``afterCommit()`` callbacks run and If no transaction is active, ``afterCommit()`` callbacks run immediately, while ``afterRollback()`` callbacks are not run. +For example: + +.. literalinclude:: transactions/011.php + +.. note:: When ``afterCommit()`` is called outside an active transaction, it runs + immediately. This includes calls from Model ``beforeInsert`` or + ``beforeUpdate`` events when the calling code has not already started a + transaction, so the callback may run before the Model's insert or update + query is executed. + This is useful for side effects that should only happen after committed data is visible, such as dispatching a queued job or sending a notification, and for cleanup that should only happen after a real rollback. @@ -139,6 +151,10 @@ Callbacks run after the database transaction has already committed or rolled back. If a callback throws an exception, that exception bubbles to the caller, but the transaction outcome is not changed. +.. warning:: When multiple callbacks are registered for the same transaction + outcome, they run in registration order. If one callback throws an exception, + the subsequent callbacks are not run. + Rollback callbacks also run when CodeIgniter automatically rolls back an active transaction while handling a transaction failure or cleaning up an unfinished transaction. diff --git a/user_guide_src/source/database/transactions/011.php b/user_guide_src/source/database/transactions/011.php new file mode 100644 index 000000000000..972911b79372 --- /dev/null +++ b/user_guide_src/source/database/transactions/011.php @@ -0,0 +1,5 @@ +db->afterCommit(static function (): void { + // Runs immediately because there is no active transaction. +});