Skip to content

Resilient Laravel helper that wraps DB::transaction() with targeted retries, exponential backoff, and structured logging to keep MySQL deadlocks from breaking your workflow.

License

Notifications You must be signed in to change notification settings

Ahed92Wakim/laravel-db-transaction-retry

Repository files navigation

Database Transaction Retry Helper

Tests Packagist Version MIT License Laravel 11 or 12 PHP ^8.2 PHP CS Fixer

Resilient database transactions for Laravel applications that need to gracefully handle deadlocks, serialization failures, and any other transient database errors you configure. This helper wraps DB::transaction() with targeted retries, structured logging, and exponential backoff so you can keep your business logic simple while surviving temporary contention.

Highlights

  • Retries known transient failures out of the box (SQLSTATE 40001, MySQL driver errors 1213 and 1205), and lets you add extra SQLSTATE codes, driver error codes, or exception classes through configuration.
  • Exponential backoff with jitter between attempts to reduce stampedes under load.
  • Structured logs with request metadata, SQL, bindings, connection information, and stack traces written to dated files under storage/logs/{Y-m-d}.
  • Log titles include the exception class and codes, making it easy to see exactly what triggered the retry.
  • Optional transaction labels and custom log file names for easier traceability across microservices and jobs.
  • Convenience DB::transactionWithRetry macro on both the facade and individual connections so existing transaction code stays readable.
  • Laravel package auto-discovery; no manual service provider registration required.

Installation

composer require ahed92wakim/laravel-db-transaction-retry

The package ships with the DatabaseTransactionRetryServiceProvider, which Laravel auto-discovers. No additional setup is needed.

Usage

use DatabaseTransactions\RetryHelper\Services\TransactionRetrier as Retry;

$order = Retry::runWithRetry(
    function () use ($payload) {
        $order = Order::create($payload);
        $order->logAuditTrail();

        return $order;
    },
    maxRetries: 4,
    retryDelay: 1,
    logFileName: 'database/transaction-retries/orders',
    trxLabel: 'order-create'
);

runWithRetry() returns the value produced by your callback, just like DB::transaction(). If every attempt fails, the last exception is re-thrown so your calling code can continue its normal error handling.

DB Macro Convenience

Prefer working through the database facade? Call the included transactionWithRetry macro and keep identical behaviour and parameters:

$invoice = DB::transactionWithRetry(
    function () use ($payload) {
        return Invoice::fromPayload($payload);
    },
    maxRetries: 5,
    retryDelay: 1,
    trxLabel: 'invoice-sync'
);

Need connection-specific logic? Because the macro is applied to Illuminate\Support\Facades\DB and to every resolved Illuminate\Database\Connection, you can call it on connection instances as well:

$report = DB::connection('analytics')->transactionWithRetry(
    fn () => $builder->lockForUpdate()->selectRaw('count(*) as total')->first(),
    trxLabel: 'analytics-rollup'
);

The macro is registered automatically when the service provider boots, and sets the tx.label container binding the same way as the helper.

Parameters

Parameter Default Description
maxRetries Config (default: 3) Total number of attempts (initial try + retries).
retryDelay Config (default: 2s) Base delay (seconds). Actual wait uses exponential backoff with ±25% jitter.
logFileName Config (default: database/transaction-retries) Written to storage/logs/{Y-m-d}/{logFileName}.log. Can point to subdirectories.
trxLabel '' Optional label injected into log titles and stored in the service container as tx.label for downstream consumers.

Call the helper anywhere you would normally open a transaction—controllers, jobs, console commands, or domain services.

Configuration

Publish the configuration file to tweak defaults globally:

php artisan vendor:publish --tag=database-transaction-retry-config
  • Key options (config/database-transaction-retry.php):

  • max_retries, retry_delay, and log_file_name set the package-wide defaults when you omit parameters. Each respects environment variables (DB_TRANSACTION_RETRY_MAX_RETRIES, DB_TRANSACTION_RETRY_DELAY, DB_TRANSACTION_RETRY_LOG_FILE).

  • lock_wait_timeout_seconds lets you override innodb_lock_wait_timeout per attempt; set the matching environment variable (DB_TRANSACTION_RETRY_LOCK_WAIT_TIMEOUT) to control the session value or leave null to use the database default.

  • logging.channel points at any existing Laravel log channel so you can reuse stacks or third-party drivers.

  • logging.levels.success / logging.levels.failure let you tune the severity emitted for successful retries and exhausted attempts (defaults: warning and error).

  • retry_on_deadlock toggles the built-in handling for MySQL deadlocks (1213). Set DB_TRANSACTION_RETRY_ON_DEADLOCK=false to disable it.

  • retry_on_lock_wait_timeout toggles retries for MySQL lock wait timeouts (1205) and activates the optional session timeout override. Set DB_TRANSACTION_RETRY_ON_LOCK_WAIT_TIMEOUT=true to enable it.

Retry Conditions

Retries are attempted when the caught exception matches one of the configured conditions:

  • Illuminate\Database\QueryException for MySQL deadlocks (1213) when retry_on_deadlock is enabled (default).
  • Illuminate\Database\QueryException for MySQL lock wait timeouts (1205) when retry_on_lock_wait_timeout is enabled.

Everything else (e.g., constraint violations, syntax errors, application exceptions) is surfaced immediately without logging or sleeping. If no attempt succeeds and all retries are exhausted, the last exception is re-thrown. In the rare case nothing is thrown but the loop exits, a RuntimeException is raised to signal exhaustion.

Lock Wait Timeout

When lock_wait_timeout_seconds is configured, the retrier issues SET SESSION innodb_lock_wait_timeout = {seconds} on the active connection before each attempt, but only when retry_on_lock_wait_timeout is enabled. This keeps the timeout predictable even after reconnects or pool reuse, and on drivers that do not support the statement the helper safely ignores the failure.

Logging Behaviour

By default, logs are written using a dedicated single-file channel per day. Override logging.channel to integrate with your own logging stack:

  • Success after retries → a warning entry titled "[trxLabel] [DATABASE TRANSACTION RETRY - SUCCESS] ExceptionClass (Codes) After (Attempts: x/y) - Warning".
  • Failure after exhausting retries → an error entry titled "[trxLabel] [DATABASE TRANSACTION RETRY - FAILED] ExceptionClass (Codes) After (Attempts: x/y) - Error".

Each log entry includes:

  • Attempt count, maximum retries, and transaction label.
  • Exception class, SQLSTATE, driver error code, connection name, SQL, resolved raw SQL, and PDO error info when available.
  • A compacted stack trace and sanitized bindings.
  • Request URL, method, authorization header length, and authenticated user ID when the request helper is bound.

Set logFileName to segment logs by feature or workload (e.g., logFileName: 'database/queues/payments').

Runtime Toggle

Use the built-in Artisan commands to temporarily disable or re-enable retries without touching configuration files:

php artisan db-transaction-retry:stop  # disable retries
php artisan db-transaction-retry:start # enable retries

The commands write a small marker file inside the package (storage/runtime/retry-disabled.marker). As long as that file exists retries stay off; removing it or running db-transaction-retry:start brings them back. You can still set the DB_TRANSACTION_RETRY_ENABLED environment variable for a permanent default.

Heads up: The db-transaction-retry:start command only removes the disable marker—it does not override an explicit database-transaction-retry.enabled=false configuration (including the DB_TRANSACTION_RETRY_ENABLED=false environment variable). Update that setting to true if you want retries to remain enabled after the current process.

Helper Utilities

The package exposes dedicated support classes you can reuse in your own instrumentation:

  • DatabaseTransactions\RetryHelper\Support\TransactionRetryLogWriter writes structured entries using the same format as the retrier.
  • DatabaseTransactions\RetryHelper\Support\TraceFormatter converts debug backtraces into log-friendly arrays.
  • DatabaseTransactions\RetryHelper\Support\BindingStringifier sanitises query bindings before logging.

For testing scenarios, the retrier looks for a namespaced DatabaseTransactions\RetryHelper\sleep() function before falling back to PHP's global sleep(), making it easy to assert backoff intervals without introducing delays.

Testing the Package

Run the test suite with:

composer test

Tests cover the retry flow, logging behaviour, exponential backoff jitter, and non-retryable scenarios using fakes for the database and logger managers.

Requirements

  • PHP >= 8.2
  • Laravel >= 11.0

Contributing

Bugs, ideas, and pull requests are welcome. Feel free to open an issue describing the problem or improvement before submitting a PR so we can collaborate on scope.

License

This package is open-sourced software released under the MIT License.

About

Resilient Laravel helper that wraps DB::transaction() with targeted retries, exponential backoff, and structured logging to keep MySQL deadlocks from breaking your workflow.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 2

  •  
  •  

Languages