Anvil
Anvil - The mobile companion for Laravel Forge. Available now. Download for iOS

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 transaction
10 User::where('type', 'old')->update(['type' => 'new']);
11 Setting::create(['key' => 'version', 'value' => '2.0']);
12 
13 // If anything fails, everything rolls back
14 }
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.php
2return [
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:

  1. All database changes are rolled back
  2. The exception is re-thrown
  3. 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 tables
    4 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 period
    2User::chunk(1000, function ($users) { /* ... */ });
  • Making external API calls

    1public function up()
    2{
    3 // Transactions won't rollback API calls
    4 Http::post('api.example.com/update', $data);
    5}
  • Using DDL statements (some databases)

    1public function up()
    2{
    3 // Schema changes may auto-commit
    4 Schema::create('new_table', function ($table) {
    5 // ...
    6 });
    7}
  • Processing huge datasets

    1// Memory and lock issues
    2Model::all()->each(function ($record) { /* ... */ });

Configuration Priority

The priority order for determining transaction usage:

  1. Patch-level $useTransaction property (highest priority)
  2. Config laravel-patches.use_transactions
  3. Default false (lowest priority)

Example:

1// Config says TRUE
2'use_transactions' => true,
3 
4// But patch says FALSE - patch wins
5class MyPatch extends Patch
6{
7 protected bool $useTransaction = false; // This takes precedence
8}

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 transaction
11 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 transaction
19 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 rollback
17 }
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 validation
12 });
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) { // Deadlock
15 $retries--;
16 sleep(1);
17 // Retry logic
18 } 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 automatically
10 $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

  1. Enable for critical patches

    1protected bool $useTransaction = true; // For data integrity
  2. Disable for long-running patches

    1protected bool $useTransaction = false; // Prevent long locks
  3. Use manual control when needed

    1// Mix transactional and non-transactional code
  4. Test both success and failure scenarios

    1test('rolls back on failure', function () { /* ... */ });
  5. Monitor transaction duration

    1Event::listen(PatchExecuted::class, function ($event) {
    2 if ($event->executionTime > 10000) {
    3 Log::warning("Long transaction: {$event->patch}");
    4 }
    5});
  6. 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}