ahed92wakim / laravel-mysql-deadlock-retry
Gracefully retry Laravel transactions on database deadlocks and other retryable errors with configurable backoff, structured logging, and reusable helpers.
Installs: 49
Dependents: 0
Suggesters: 0
Security: 0
Stars: 4
Watchers: 1
Forks: 1
Open Issues: 1
pkg:composer/ahed92wakim/laravel-mysql-deadlock-retry
Requires
- php: ^8.2
- laravel/framework: ^11.0 || ^12.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.88
- pestphp/pest: ^3.8
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5
README
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, retry event persistence, 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 errors1213and1205), 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.
- Retry events persisted to
transaction_retry_eventswith request metadata, SQL, bindings, connection information, and stack traces. - Captures exception classes and codes, making it easy to see exactly what triggered the retry.
- Optional transaction labels and log file names (when using the log driver) for easier traceability across microservices and jobs.
- 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) |
Used only when logging.driver=log. 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
You can also run php artisan db-transaction-retry:install to publish the config, migrations, auth provider stub, and dashboard in one step.
-
Key options (
config/database-transaction-retry.php): -
max_retries,retry_delay, andlog_file_nameset 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).log_file_nameonly applies whenlogging.driver=log. -
state_pathcontrols where the retry toggle marker file is stored (defaults tostorage_path('database-transaction-retry/runtime')). SetDB_TRANSACTION_RETRY_STATE_PATHif your deployment requires a custom writable location. -
lock_wait_timeout_secondslets you overrideinnodb_lock_wait_timeoutper 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.driverselects where retry events are stored:database(default) orlog. -
logging.tablelets you override the database table name (defaults totransaction_retry_events). -
logging.channelpoints at any existing Laravel log channel so you can reuse stacks or third-party drivers when using the log driver. -
logging.levels.success/logging.levels.failure/logging.levels.attemptlet you tune the severity emitted for successful retries, per-attempt entries, and exhausted attempts (defaults:warning,warning, anderror). -
retryable_exceptions.sql_stateslists SQLSTATE codes that should trigger a retry (defaults to40001). -
retryable_exceptions.driver_error_codeslists driver-specific error codes (defaults to1213deadlocks and1205lock wait timeouts). Including1205not only enables retries but also activates the optional session lock wait timeout override when configured. -
retryable_exceptions.classeslets you specify fully-qualified exception class names that should always be retried. -
dashboard.pathsets the UI path (defaults to/transaction-retry). -
dashboard.middlewarelets you attach middleware to the dashboard UI route (for exampleweb,auth, orcan:viewTransactionRetryDashboard). -
api.prefixsets the JSON API prefix (defaults to/api/transaction-retry). -
api.middlewarelets you attach middleware to the JSON API routes (for exampleweb,auth, or a custom gate).
Database Migration
Retry events are stored in the database by default. Publish the migration and run it:
php artisan db-transaction-retry:install php artisan migrate
Or publish manually:
php artisan vendor:publish --tag=database-transaction-retry-migrations php artisan migrate
If you switch to logging.driver=log, the migration is optional.
Dashboard (Next.js UI)
The package ships a static Next.js dashboard that is published into your Laravel app's public/vendor/laravel-db-transaction-retry/{dashboard.path} directory. By default, it is available at:
- UI:
/transaction-retry - API:
/api/transaction-retry/events
Securing the dashboard
By default the dashboard uses the AuthorizeTransactionRetryDashboard middleware, which only allows access in
the local environment until you define your own authorization logic (mirroring Telescope). The install command
publishes an app/Providers/TransactionRetryDashboardServiceProvider.php stub you can edit.
To customize access, define a gate and register the authorization callback in one of your application service providers:
use DatabaseTransactions\RetryHelper\TransactionRetryDashboard; use Illuminate\Support\Facades\Gate; Gate::define('viewTransactionRetryDashboard', function ($user) { return in_array($user->email, [ // ... ], true); }); TransactionRetryDashboard::auth(function ($request) { return app()->environment('local') || Gate::check('viewTransactionRetryDashboard', [$request->user()]); });
Register the published service provider in bootstrap/providers.php (Laravel 11/12):
return [ App\Providers\TransactionRetryDashboardServiceProvider::class, ];
You can also swap the middleware stack for the dashboard and API routes in config/database-transaction-retry.php:
'dashboard' => [ 'middleware' => ['web', 'auth', 'can:viewTransactionRetryDashboard'], ], 'api' => [ 'middleware' => ['web', 'auth', 'can:viewTransactionRetryDashboard'], ],
Define the viewTransactionRetryDashboard gate in your application (or swap in any other middleware).
Publish the dashboard assets:
php artisan vendor:publish --tag=database-transaction-retry-dashboard
The db-transaction-retry:install command publishes the dashboard too.
Rebuilding the UI (package contributors)
If you are working on the package itself and want to rebuild the dashboard:
cd dashboard
npm install
npm run build
This writes static assets to dashboard/out, which are published to the host app's public/vendor/laravel-db-transaction-retry/{dashboard.path} directory.
Uninstall
When you remove the package with Composer, the service provider listens for the
composer_package.ahed92wakim/laravel-db-transaction-retry:pre_uninstall event and
cleans up published assets (similar to Telescope). It also removes the published
dashboard service provider from bootstrap/providers.php when available. It deletes:
config/database-transaction-retry.phpapp/Providers/TransactionRetryDashboardServiceProvider.php- Published migrations for the retry events and exception tables
- The published dashboard assets under
public/vendor/laravel-db-transaction-retry/{dashboard.path}
Database tables are not dropped automatically. If you want to remove them, drop the tables manually or run your own cleanup migration.
Partition Maintenance (MySQL)
The migration creates hourly partitions for MySQL. Keep partitions rolling by scheduling the command to run hourly:
use Illuminate\Support\Facades\Schedule; Schedule::command('db-transaction-retry:roll-partitions --hours=24 --table=transaction_retry_events')->hourly(); Schedule::command('db-transaction-retry:roll-partitions --hours=24 --table=db_transaction_logs')->hourly(); Schedule::command('db-transaction-retry:roll-partitions --hours=24 --table=db_exceptions')->hourly();
Make sure your scheduler is running (for example, the standard schedule:run cron).
Retry Conditions
Retries are attempted when the caught exception matches one of the configured conditions:
Illuminate\Database\QueryExceptionfor MySQL deadlocks (1213) whenretry_on_deadlockis enabled (default).Illuminate\Database\QueryExceptionfor MySQL lock wait timeouts (1205) whenretry_on_lock_wait_timeoutis 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.
Retry Event Storage
By default, retry events are stored in the transaction_retry_events table. Each retryable exception attempt is persisted, plus a final success or failure entry once the retrier finishes. If you prefer file logging, set logging.driver=log and optionally choose a log channel:
- 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 retry event stores:
- Attempt count, maximum retries, transaction label, and retry status (attempt/success/failure).
- A retry group ID to correlate attempts and the final outcome for a single transaction.
- Exception class, SQLSTATE, driver error code, connection name, SQL, bindings, resolved raw SQL, and PDO error info when available.
- A compacted stack trace and request URL, method, authorization header length, and authenticated user ID/type (UUID or integer) when the request helper is bound.
Set logFileName to segment logs by feature or workload (e.g., logFileName: 'database/queues/payments') when using the log driver.
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:startcommand only removes the disable marker—it does not override an explicitdatabase-transaction-retry.enabled=falseconfiguration (including theDB_TRANSACTION_RETRY_ENABLED=falseenvironment variable). Update that setting totrueif 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\TransactionRetryLogWriterwrites retry events to the configured driver (database or log).DatabaseTransactions\RetryHelper\Support\TraceFormatterconverts debug backtraces into log-friendly arrays.DatabaseTransactions\RetryHelper\Support\BindingStringifiersanitises 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
Changelog
Notable changes are tracked in CHANGELOG.md.
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.