๐ Enjoying this package? Consider sponsoring me on GitHub or buying me a beer.
Transactions
Laravel Patches supports wrapping patch execution in database transactions, providing automatic rollback on failures and ensuring data consistency.
Overview
When transactions are enabled, if a patch throws an exception, all database changes made by that patch are automatically rolled back.
Enabling Transactions
Per-Patch Basis
Enable transactions for a specific patch:
1use Rappasoft\LaravelPatches\Patch; 2 3class MyPatch extends Patch 4{ 5 protected bool $useTransaction = true; 6 7 public function up() 8 { 9 // All database operations here will be wrapped in a transaction10 User::where('type', 'old')->update(['type' => 'new']);11 Setting::create(['key' => 'version', 'value' => '2.0']);12 13 // If anything fails, everything rolls back14 }15 16 public function down()17 {18 User::where('type', 'new')->update(['type' => 'old']);19 Setting::where('key', 'version')->delete();20 }21}
Globally
Enable transactions for all patches by default:
1// config/laravel-patches.php2return [3 'use_transactions' => env('PATCHES_USE_TRANSACTIONS', false),4];
Or in .env:
1PATCHES_USE_TRANSACTIONS=true
How It Works
When $useTransaction = true:
1DB::transaction(function () {2 $patch->up();3});
If an exception is thrown:
- All database changes are rolled back
- The exception is re-thrown
- No changes persist to the database
When to Use Transactions
โ Use Transactions When:
-
Updating multiple related records
1public function up()2{3 User::all()->each(function ($user) {4 $user->profile->update(['verified' => true]);5 $user->update(['status' => 'active']);6 });7} -
Data must be consistent
1public function up()2{3 // Transfer data between tables4 OldTable::chunk(100, function ($records) {5 foreach ($records as $record) {6 NewTable::create($record->toArray());7 $record->delete();8 }9 });10} -
Testing patches (easy rollback)
1protected bool $useTransaction = true; // For development
โ Avoid Transactions When:
-
Running long operations
1// This will lock tables for extended period2User::chunk(1000, function ($users) { /* ... */ }); -
Making external API calls
1public function up()2{3 // Transactions won't rollback API calls4 Http::post('api.example.com/update', $data);5} -
Using DDL statements (some databases)
1public function up()2{3 // Schema changes may auto-commit4 Schema::create('new_table', function ($table) {5 // ...6 });7} -
Processing huge datasets
1// Memory and lock issues2Model::all()->each(function ($record) { /* ... */ });
Configuration Priority
The priority order for determining transaction usage:
- Patch-level
$useTransactionproperty (highest priority) - Config
laravel-patches.use_transactions - Default
false(lowest priority)
Example:
1// Config says TRUE2'use_transactions' => true,3 4// But patch says FALSE - patch wins5class MyPatch extends Patch6{7 protected bool $useTransaction = false; // This takes precedence8}
Manual Transaction Control
For complex scenarios, you can manually control transactions:
1use Rappasoft\LaravelPatches\Patch; 2use Illuminate\Support\Facades\DB; 3 4class ComplexPatch extends Patch 5{ 6 protected bool $useTransaction = false; // Disable automatic 7 8 public function up() 9 {10 // Part 1 - in transaction11 DB::transaction(function () {12 User::where('type', 'A')->update(['status' => 'active']);13 });14 15 // Part 2 - NOT in transaction (external API)16 Http::post('api.example.com/notify');17 18 // Part 3 - in new transaction19 DB::transaction(function () {20 Log::create(['action' => 'patch_completed']);21 });22 }23}
Nested Transactions
Laravel uses savepoints for nested transactions:
1public function up() 2{ 3 DB::transaction(function () { 4 User::create(['name' => 'John']); 5 6 DB::transaction(function () { 7 // Nested - uses savepoint 8 Profile::create(['user_id' => 1]); 9 });10 });11}
Handling Transaction Failures
Catching and Logging
1use Rappasoft\LaravelPatches\Patch; 2 3class SafePatch extends Patch 4{ 5 protected bool $useTransaction = true; 6 7 public function up() 8 { 9 try {10 User::where('old_status', 'pending')11 ->update(['status' => 'active']);12 13 $this->log('Updated user statuses');14 } catch (\Exception $e) {15 $this->log('Failed to update: ' . $e->getMessage());16 throw $e; // Re-throw to trigger rollback17 }18 }19}
Partial Rollback
1public function up() 2{ 3 // This succeeds and commits 4 DB::transaction(function () { 5 User::create(['name' => 'Alice']); 6 }); 7 8 // This fails and rolls back (only its changes) 9 try {10 DB::transaction(function () {11 User::create(['email' => 'invalid']); // Fails validation12 });13 } catch (\Exception $e) {14 $this->log('Second transaction failed, but first succeeded');15 }16}
Performance Considerations
Large Datasets
For large datasets, use chunking WITHOUT wrapping everything in one transaction:
1class LargeDataPatch extends Patch 2{ 3 protected bool $useTransaction = false; 4 5 public function up() 6 { 7 User::chunk(500, function ($users) { 8 // Each chunk in its own transaction 9 DB::transaction(function () use ($users) {10 foreach ($users as $user) {11 $user->update(['migrated' => true]);12 }13 });14 });15 }16}
Deadlock Prevention
1public function up() 2{ 3 // Process in smaller batches to reduce lock contention 4 $retries = 3; 5 6 User::chunk(100, function ($users) use (&$retries) { 7 try { 8 DB::transaction(function () use ($users) { 9 foreach ($users as $user) {10 $user->processUpdate();11 }12 });13 } catch (\Illuminate\Database\QueryException $e) {14 if ($e->getCode() === '40001' && $retries > 0) { // Deadlock15 $retries--;16 sleep(1);17 // Retry logic18 } else {19 throw $e;20 }21 }22 });23}
Testing with Transactions
Transactions are especially useful in testing:
1test('patch updates users correctly', function () { 2 $patch = new MyPatch(); 3 $patch->useTransaction = true; 4 5 try { 6 $patch->up(); 7 $this->fail('Expected exception was not thrown'); 8 } catch (\Exception $e) { 9 // Transaction rolled back automatically10 $this->assertEquals(0, User::where('migrated', true)->count());11 }12});
Database-Specific Behavior
MySQL/MariaDB
- DDL statements (CREATE, ALTER, DROP) cause implicit commits
- Transactions work well for DML (INSERT, UPDATE, DELETE)
PostgreSQL
- Full support for transactional DDL
- Can rollback schema changes
SQLite
- Transactions work for all operations
- Good for testing
Best Practices
-
Enable for critical patches
1protected bool $useTransaction = true; // For data integrity -
Disable for long-running patches
1protected bool $useTransaction = false; // Prevent long locks -
Use manual control when needed
1// Mix transactional and non-transactional code -
Test both success and failure scenarios
1test('rolls back on failure', function () { /* ... */ }); -
Monitor transaction duration
1Event::listen(PatchExecuted::class, function ($event) {2 if ($event->executionTime > 10000) {3 Log::warning("Long transaction: {$event->patch}");4 }5}); -
Document transaction usage
1/**2 * Updates user roles across multiple tables.3 * Uses transactions to ensure consistency.4 */5protected bool $useTransaction = true;
Troubleshooting
Transaction Timeout
If patches take too long:
1DB::statement('SET SESSION max_execution_time = 300000'); // 5 minutes
Lock Wait Timeout
1DB::statement('SET SESSION innodb_lock_wait_timeout = 120');
Checking Transaction Status
1public function up()2{3 if (DB::transactionLevel() > 0) {4 $this->log('Currently in a transaction');5 }6}