diff --git a/.gitignore b/.gitignore
index b960e00..849579f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,4 +3,4 @@ vendor/
.idea/
.vscode/
-.DS_Store
+.DS_Store
\ No newline at end of file
diff --git a/.php-cs-fixer.cache b/.php-cs-fixer.cache
index cf130ad..f3e784c 100644
--- a/.php-cs-fixer.cache
+++ b/.php-cs-fixer.cache
@@ -1 +1 @@
-{"php":"8.2.29","version":"3.88.2:v3.88.2#a8d15584bafb0f0d9d938827840060fd4a3ebc99","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"align_single_space_minimal"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"single_quote":true,"no_unused_imports":true,"no_superfluous_phpdoc_tags":true,"phpdoc_trim":true,"phpdoc_align":{"align":"left"},"blank_line_before_statement":{"statements":["return"]},"simplified_null_return":true,"void_return":true},"hashes":{"src\/Helper.php":"8ef8db53eed02278815b175b445a2ee9","src\/DBTransactionRetryHelperOld.php":"358e3a95a390c013376e05cb0911b599","src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","src\/RetryServiceProvider.php":"b6642465f4ed70477d21c0460e3677df","tests\/TestCase.php":"78359ccc3cc8934fc6b788bb9f8455df","tests\/bootstrap.php":"8af7490a2832c4cce20f0980636bad41","tests\/DBTransactionRetryHelperTest.php":"5e9993c586d9318449b2181ece54bc73","\/tmp\/PHP CS Fixertemp_folder\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder1\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20",".php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder2\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder10\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder4\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder5\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder11\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder9\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder815\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder8\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder3\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder7\/.php-cs-fixer.php":"f08f20b53da80da9c95f886b732fca20","\/tmp\/PHP CS Fixertemp_folder6\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","\/tmp\/PHP CS Fixertemp_folder\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","\/tmp\/PHP CS Fixertemp_folder1\/src\/DBTransactionRetryHelper.php":"3dfeb60b0234603d2046f39592b6a547","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"0a3d3a6bed6c3bd5af47a71e29a5be92","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b","src\/Providers\/DatabaseRetryServiceProvider.php":"c4b1b48a744c843ed40bb818370ab922","src\/Services\/DeadlockTransactionRetrier.php":"62fc72973cf461c9029fdddaa4d721ce","src\/Support\/DeadlockLogWriter.php":"b298e47ae03b1255eb9d09d8c3758ef4","src\/Support\/BindingStringifier.php":"3aa21139dad20340d9518fa57e0845ca","src\/Support\/TraceFormatter.php":"13f19f8c9de611faa05847ae3890b73d"}}
\ No newline at end of file
+{"php":"8.2.29","version":"3.89.0:v3.89.0#4dd6768cb7558440d27d18f54909eee417317ce9","indent":" ","lineEnding":"\n","rules":{"binary_operator_spaces":{"default":"align_single_space_minimal"},"blank_line_after_opening_tag":true,"blank_line_between_import_groups":true,"blank_lines_before_namespace":true,"braces_position":{"allow_single_line_empty_anonymous_classes":true},"class_definition":{"inline_constructor_arguments":false,"space_before_parenthesis":true},"compact_nullable_type_declaration":true,"declare_equal_normalize":true,"lowercase_cast":true,"lowercase_static_reference":true,"modifier_keywords":true,"new_with_parentheses":{"anonymous_class":true},"no_blank_lines_after_class_opening":true,"no_extra_blank_lines":true,"no_leading_import_slash":true,"no_whitespace_in_blank_line":true,"ordered_class_elements":{"order":["use_trait"]},"ordered_imports":{"sort_algorithm":"alpha"},"return_type_declaration":true,"short_scalar_cast":true,"single_import_per_statement":{"group_to_single_imports":false},"single_space_around_construct":{"constructs_followed_by_a_single_space":["abstract","as","case","catch","class","const_import","do","else","elseif","final","finally","for","foreach","function","function_import","if","insteadof","interface","namespace","new","private","protected","public","static","switch","trait","try","use","use_lambda","while"],"constructs_preceded_by_a_single_space":["as","else","elseif","use_lambda"]},"single_trait_insert_per_statement":true,"ternary_operator_spaces":true,"unary_operator_spaces":{"only_dec_inc":true},"blank_line_after_namespace":true,"constant_case":true,"control_structure_braces":true,"control_structure_continuation_position":true,"elseif":true,"function_declaration":{"closure_fn_spacing":"one"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"after_heredoc":false,"attribute_placement":"ignore","on_multiline":"ensure_fully_multiline"},"no_break_comment":true,"no_closing_tag":true,"no_multiple_statements_per_line":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_line_after_imports":true,"spaces_inside_parentheses":true,"statement_indentation":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"single_quote":true,"no_unused_imports":true,"no_superfluous_phpdoc_tags":true,"phpdoc_trim":true,"phpdoc_align":{"align":"left"},"blank_line_before_statement":{"statements":["return"]},"simplified_null_return":true,"void_return":true},"hashes":{"tests\/bootstrap.php":"4ae74313e457f6662f4831ee2140eb34","src\/Providers\/DatabaseTransactionRetryServiceProvider.php":"1071a19b80835472e035374cc17b7056","src\/Services\/TransactionRetrier.php":"7d99d773c44861e3f12524a5119f5240","src\/Support\/BindingStringifier.php":"bbead2bae37761124652320cd28db412","src\/Support\/TransactionRetryLogWriter.php":"2bc60103437973d7b471ad6bc91c43cf","src\/Support\/TraceFormatter.php":"e640e32b17149f1cfec9a1d990f04c84","tests\/TestCase.php":"a45ed4c82e3b3f4ad47544b81fda41f5","tests\/Unit\/ExampleTest.php":"3bbd4ea8029698f723c35a66d8592087","tests\/Unit\/DBTransactionRetryHelperTest.php":"07bb6b8e6c8b3ce61a7e67f128f12f4a","tests\/Feature\/ExampleTest.php":"a1e5352ea369ad36f88f4f566c340371","tests\/Pest.php":"44a41307b2bca2c9b747aa2f40c5262b"}}
\ No newline at end of file
diff --git a/README.md b/README.md
index 8e72f6f..bd7f709 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,13 @@
-
+
-
-
+
+
-
-
+
+
@@ -18,29 +18,29 @@
-Resilient database transactions for Laravel applications that need to gracefully handle MySQL deadlocks and serialization failures. This helper wraps `DB::transaction()` with targeted retries, structured logging, and exponential backoff so you can keep your business logic simple while surviving transient contention.
+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 only known transient failure scenarios (MySQL driver error `1213` and SQLSTATE `40001`), leaving all other exceptions untouched.
+- 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}`.
-- Safe in HTTP, CLI, and queue contexts: request data is collected when available and ignored when not.
+- 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.
- Laravel package auto-discovery; no manual service provider registration required.
## Installation
```bash
-composer require ahed92wakim/laravel-mysql-deadlock-retry
+composer require ahed92wakim/laravel-db-transaction-retry
```
-The package ships with the `DatabaseRetryServiceProvider`, which Laravel auto-discovers. No additional setup is needed.
+The package ships with the `DatabaseTransactionRetryServiceProvider`, which Laravel auto-discovers. No additional setup is needed.
## Usage
```php
-use MysqlDeadlocks\RetryHelper\Services\DeadlockTransactionRetrier as Retry;
+use DatabaseTransactions\RetryHelper\Services\TransactionRetrier as Retry;
$order = Retry::runWithRetry(
function () use ($payload) {
@@ -51,21 +51,21 @@ $order = Retry::runWithRetry(
},
maxRetries: 4,
retryDelay: 1,
- logFileName: 'mysql-deadlocks/orders',
+ 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 `QueryException` is re-thrown so your calling code can continue its normal error handling.
+`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.
### 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/mysql-deadlocks`) | 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. |
+| 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.
@@ -74,39 +74,44 @@ Call the helper anywhere you would normally open a transaction—controllers, jo
Publish the configuration file to tweak defaults globally:
```bash
-php artisan vendor:publish --tag=mysql-deadlock-retry-config
+php artisan vendor:publish --tag=database-transaction-retry-config
```
-Key options (`config/mysql-deadlock-retry.php`):
+- 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 the matching environment variable (`MYSQL_DEADLOCK_MAX_RETRIES`, `MYSQL_DEADLOCK_RETRY_DELAY`, `MYSQL_DEADLOCK_LOG_FILE`).
+- `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.config` provides a full configuration array for `Log::build()` when you want a dedicated writer.
-- `logging.via` accepts a container binding, class name, or callable that resolves a PSR-3 logger—ideal when you need to hand logs off to a completely custom pipeline.
- `logging.levels.success` / `logging.levels.failure` let you tune the severity emitted for successful retries and exhausted attempts (defaults: `warning` and `error`).
+- `retryable_exceptions.sql_states` lists SQLSTATE codes that should trigger a retry (defaults to `40001`).
+- `retryable_exceptions.driver_error_codes` lists driver-specific error codes (defaults to `1213` deadlocks and `1205` lock wait timeouts). Including `1205` not only enables retries but also activates the optional session lock wait timeout override when configured.
+- `retryable_exceptions.classes` lets you specify fully-qualified exception class names that should always be retried.
## Retry Conditions
-Retries are attempted only when the caught exception is an `Illuminate\Database\QueryException` that matches one of:
+Retries are attempted when the caught exception matches one of the configured conditions:
-- SQLSTATE `40001` (serialization failure).
-- MySQL driver error `1213` (deadlock), whether reported via SQLSTATE or the driver error code.
+- `Illuminate\Database\QueryException` with a SQLSTATE listed in `retryable_exceptions.sql_states`.
+- `Illuminate\Database\QueryException` with a driver error code listed in `retryable_exceptions.driver_error_codes` (defaults include `1213` deadlocks and `1205` lock wait timeouts).
+- Any exception instance whose class appears in `retryable_exceptions.classes`.
-Everything else (e.g., constraint violations, syntax errors, driver error `1205`, application exceptions) is surfaced immediately without logging or sleeping.
+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.
-If no attempt succeeds and all retries are exhausted, the last `QueryException` 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 the retry rules include the lock-wait timeout driver code (`1205`). 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`, `logging.config`, or `logging.via` to integrate with your own logging stack:
+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] [MYSQL DEADLOCK RETRY - SUCCESS] After (Attempts: x/y) - Warning"`.
-- Failure after exhausting retries → an error entry titled `"[trxLabel] [MYSQL DEADLOCK RETRY - FAILED] After (Attempts: x/y) - Error"`.
+- 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.
-- Connection name, SQL, resolved raw SQL (when bindings are available), and PDO error info.
+- 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.
@@ -116,11 +121,11 @@ Set `logFileName` to segment logs by feature or workload (e.g., `logFileName: 'd
The package exposes dedicated support classes you can reuse in your own instrumentation:
-- `MysqlDeadlocks\RetryHelper\Support\DeadlockLogWriter` writes structured entries using the same format as the retrier.
-- `MysqlDeadlocks\RetryHelper\Support\TraceFormatter` converts debug backtraces into log-friendly arrays.
-- `MysqlDeadlocks\RetryHelper\Support\BindingStringifier` sanitises query bindings before logging.
+- `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 `MysqlDeadlocks\RetryHelper\sleep()` function before falling back to PHP's global `sleep()`, making it easy to assert backoff intervals without introducing delays.
+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
@@ -130,7 +135,7 @@ Run the test suite with:
composer test
```
-Tests cover the retry flow, logging behaviour, exponential backoff jitter, and non-deadlock scenarios using fakes for the database and logger managers.
+Tests cover the retry flow, logging behaviour, exponential backoff jitter, and non-retryable scenarios using fakes for the database and logger managers.
## Requirements
diff --git a/composer.json b/composer.json
index 48f7038..5e55370 100644
--- a/composer.json
+++ b/composer.json
@@ -1,18 +1,18 @@
{
- "name": "ahed92wakim/laravel-mysql-deadlock-retry",
- "description": "Gracefully retry Laravel transactions on MySQL deadlocks and serialization failures with configurable backoff, structured logging, and reusable helpers.",
+ "name": "ahed92wakim/laravel-db-transaction-retry",
+ "description": "Gracefully retry Laravel transactions on database deadlocks and other retryable errors with configurable backoff, structured logging, and reusable helpers.",
"type": "library",
"minimum-stability": "stable",
"require": {
"php": "^8.2",
- "laravel/framework": "^11.0"
+ "laravel/framework": "^11.0 || ^12.0"
},
"license": [
"MIT"
],
"autoload": {
"psr-4": {
- "MysqlDeadlocks\\RetryHelper\\": "src/"
+ "DatabaseTransactions\\RetryHelper\\": "src/"
}
},
"autoload-dev": {
@@ -23,7 +23,7 @@
"extra": {
"laravel": {
"providers": [
- "MysqlDeadlocks\\RetryHelper\\Providers\\DatabaseRetryServiceProvider"
+ "DatabaseTransactions\\RetryHelper\\Providers\\DatabaseTransactionRetryServiceProvider"
]
}
},
diff --git a/composer.lock b/composer.lock
index 6b429c6..2125ee2 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "b6dd3735e10c02ef8c3fcd8383840434",
+ "content-hash": "aca48630e0c6f79b93cfb34a99ccacbb",
"packages": [
{
"name": "brick/math",
@@ -1055,20 +1055,20 @@
},
{
"name": "laravel/framework",
- "version": "v11.46.1",
+ "version": "v12.35.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
- "reference": "5fd457f807570a962a53b403b1346efe4cc80bb8"
+ "reference": "9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/framework/zipball/5fd457f807570a962a53b403b1346efe4cc80bb8",
- "reference": "5fd457f807570a962a53b403b1346efe4cc80bb8",
+ "url": "https://api.github.com/repos/laravel/framework/zipball/9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef",
+ "reference": "9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef",
"shasum": ""
},
"require": {
- "brick/math": "^0.9.3|^0.10.2|^0.11|^0.12|^0.13|^0.14",
+ "brick/math": "^0.11|^0.12|^0.13|^0.14",
"composer-runtime-api": "^2.2",
"doctrine/inflector": "^2.0.5",
"dragonmantank/cron-expression": "^3.4",
@@ -1083,32 +1083,34 @@
"fruitcake/php-cors": "^1.3",
"guzzlehttp/guzzle": "^7.8.2",
"guzzlehttp/uri-template": "^1.0",
- "laravel/prompts": "^0.1.18|^0.2.0|^0.3.0",
+ "laravel/prompts": "^0.3.0",
"laravel/serializable-closure": "^1.3|^2.0",
"league/commonmark": "^2.7",
"league/flysystem": "^3.25.1",
"league/flysystem-local": "^3.25.1",
"league/uri": "^7.5.1",
"monolog/monolog": "^3.0",
- "nesbot/carbon": "^2.72.6|^3.8.4",
+ "nesbot/carbon": "^3.8.4",
"nunomaduro/termwind": "^2.0",
"php": "^8.2",
"psr/container": "^1.1.1|^2.0.1",
"psr/log": "^1.0|^2.0|^3.0",
"psr/simple-cache": "^1.0|^2.0|^3.0",
"ramsey/uuid": "^4.7",
- "symfony/console": "^7.0.3",
- "symfony/error-handler": "^7.0.3",
- "symfony/finder": "^7.0.3",
+ "symfony/console": "^7.2.0",
+ "symfony/error-handler": "^7.2.0",
+ "symfony/finder": "^7.2.0",
"symfony/http-foundation": "^7.2.0",
- "symfony/http-kernel": "^7.0.3",
- "symfony/mailer": "^7.0.3",
- "symfony/mime": "^7.0.3",
- "symfony/polyfill-php83": "^1.31",
- "symfony/process": "^7.0.3",
- "symfony/routing": "^7.0.3",
- "symfony/uid": "^7.0.3",
- "symfony/var-dumper": "^7.0.3",
+ "symfony/http-kernel": "^7.2.0",
+ "symfony/mailer": "^7.2.0",
+ "symfony/mime": "^7.2.0",
+ "symfony/polyfill-php83": "^1.33",
+ "symfony/polyfill-php84": "^1.33",
+ "symfony/polyfill-php85": "^1.33",
+ "symfony/process": "^7.2.0",
+ "symfony/routing": "^7.2.0",
+ "symfony/uid": "^7.2.0",
+ "symfony/var-dumper": "^7.2.0",
"tijsverkoyen/css-to-inline-styles": "^2.2.5",
"vlucas/phpdotenv": "^5.6.1",
"voku/portable-ascii": "^2.0.2"
@@ -1140,6 +1142,7 @@
"illuminate/filesystem": "self.version",
"illuminate/hashing": "self.version",
"illuminate/http": "self.version",
+ "illuminate/json-schema": "self.version",
"illuminate/log": "self.version",
"illuminate/macroable": "self.version",
"illuminate/mail": "self.version",
@@ -1172,17 +1175,18 @@
"league/flysystem-read-only": "^3.25.1",
"league/flysystem-sftp-v3": "^3.25.1",
"mockery/mockery": "^1.6.10",
- "orchestra/testbench-core": "^9.16.1",
- "pda/pheanstalk": "^5.0.6",
+ "opis/json-schema": "^2.4.1",
+ "orchestra/testbench-core": "^10.7.0",
+ "pda/pheanstalk": "^5.0.6|^7.0.0",
"php-http/discovery": "^1.15",
"phpstan/phpstan": "^2.0",
- "phpunit/phpunit": "^10.5.35|^11.3.6|^12.0.1",
- "predis/predis": "^2.3",
+ "phpunit/phpunit": "^10.5.35|^11.5.3|^12.0.1",
+ "predis/predis": "^2.3|^3.0",
"resend/resend-php": "^0.10.0",
- "symfony/cache": "^7.0.3",
- "symfony/http-client": "^7.0.3",
- "symfony/psr-http-message-bridge": "^7.0.3",
- "symfony/translation": "^7.0.3"
+ "symfony/cache": "^7.2.0",
+ "symfony/http-client": "^7.2.0",
+ "symfony/psr-http-message-bridge": "^7.2.0",
+ "symfony/translation": "^7.2.0"
},
"suggest": {
"ably/ably-php": "Required to use the Ably broadcast driver (^1.0).",
@@ -1197,7 +1201,7 @@
"ext-pdo": "Required to use all database features.",
"ext-posix": "Required to use all features of the queue worker.",
"ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0|^6.0).",
- "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).",
+ "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).",
"filp/whoops": "Required for friendly error pages in development (^2.14.3).",
"laravel/tinker": "Required to use the tinker console command (^2.0).",
"league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).",
@@ -1208,22 +1212,22 @@
"mockery/mockery": "Required to use mocking (^1.6).",
"pda/pheanstalk": "Required to use the beanstalk queue driver (^5.0).",
"php-http/discovery": "Required to use PSR-7 bridging features (^1.15).",
- "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.3.6|^12.0.1).",
- "predis/predis": "Required to use the predis connector (^2.3).",
+ "phpunit/phpunit": "Required to use assertions and run tests (^10.5.35|^11.5.3|^12.0.1).",
+ "predis/predis": "Required to use the predis connector (^2.3|^3.0).",
"psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).",
"pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).",
"resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0).",
- "symfony/cache": "Required to PSR-6 cache bridge (^7.0).",
- "symfony/filesystem": "Required to enable support for relative symbolic links (^7.0).",
- "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.0).",
- "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.0).",
- "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.0).",
- "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.0)."
+ "symfony/cache": "Required to PSR-6 cache bridge (^7.2).",
+ "symfony/filesystem": "Required to enable support for relative symbolic links (^7.2).",
+ "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.2).",
+ "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.2).",
+ "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.2).",
+ "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.2)."
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "11.x-dev"
+ "dev-master": "12.x-dev"
}
},
"autoload": {
@@ -1266,7 +1270,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
- "time": "2025-09-30T14:51:32+00:00"
+ "time": "2025-10-21T15:15:41+00:00"
},
{
"name": "laravel/prompts",
@@ -4642,6 +4646,166 @@
],
"time": "2025-07-08T02:45:35+00:00"
},
+ {
+ "name": "symfony/polyfill-php84",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php84.git",
+ "reference": "d8ced4d875142b6a7426000426b8abc631d6b191"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191",
+ "reference": "d8ced4d875142b6a7426000426b8abc631d6b191",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php84\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-06-24T13:30:11+00:00"
+ },
+ {
+ "name": "symfony/polyfill-php85",
+ "version": "v1.33.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/polyfill-php85.git",
+ "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
+ "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.2"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
+ }
+ },
+ "autoload": {
+ "files": [
+ "bootstrap.php"
+ ],
+ "psr-4": {
+ "Symfony\\Polyfill\\Php85\\": ""
+ },
+ "classmap": [
+ "Resources/stubs"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "compatibility",
+ "polyfill",
+ "portable",
+ "shim"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2025-06-23T16:12:55+00:00"
+ },
{
"name": "symfony/polyfill-uuid",
"version": "v1.33.0",
@@ -9539,86 +9703,6 @@
],
"time": "2024-09-09T11:45:10+00:00"
},
- {
- "name": "symfony/polyfill-php84",
- "version": "v1.33.0",
- "source": {
- "type": "git",
- "url": "https://github.com/symfony/polyfill-php84.git",
- "reference": "d8ced4d875142b6a7426000426b8abc631d6b191"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191",
- "reference": "d8ced4d875142b6a7426000426b8abc631d6b191",
- "shasum": ""
- },
- "require": {
- "php": ">=7.2"
- },
- "type": "library",
- "extra": {
- "thanks": {
- "url": "https://github.com/symfony/polyfill",
- "name": "symfony/polyfill"
- }
- },
- "autoload": {
- "files": [
- "bootstrap.php"
- ],
- "psr-4": {
- "Symfony\\Polyfill\\Php84\\": ""
- },
- "classmap": [
- "Resources/stubs"
- ]
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Nicolas Grekas",
- "email": "p@tchwork.com"
- },
- {
- "name": "Symfony Community",
- "homepage": "https://symfony.com/contributors"
- }
- ],
- "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions",
- "homepage": "https://symfony.com",
- "keywords": [
- "compatibility",
- "polyfill",
- "portable",
- "shim"
- ],
- "support": {
- "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0"
- },
- "funding": [
- {
- "url": "https://symfony.com/sponsor",
- "type": "custom"
- },
- {
- "url": "https://github.com/fabpot",
- "type": "github"
- },
- {
- "url": "https://github.com/nicolas-grekas",
- "type": "github"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
- "type": "tidelift"
- }
- ],
- "time": "2025-06-24T13:30:11+00:00"
- },
{
"name": "symfony/stopwatch",
"version": "v7.3.0",
diff --git a/config/database-transaction-retry.php b/config/database-transaction-retry.php
new file mode 100644
index 0000000..0021ce6
--- /dev/null
+++ b/config/database-transaction-retry.php
@@ -0,0 +1,81 @@
+ (int) env('DB_TRANSACTION_RETRY_MAX_RETRIES', 3),
+
+ 'retry_delay' => (int) env('DB_TRANSACTION_RETRY_DELAY', 2),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Logging
+ |--------------------------------------------------------------------------
+ |
+ | Control how retry attempts are logged. Provide a `channel` to reuse any
+ | logging channel defined in your application, supply a `config` array to
+ | build a dedicated logger on the fly. When none are defined,
+ | the package will continue to emit dated single-file logs per prior
+ | behaviour.
+ |
+ */
+
+ 'log_file_name' => env('DB_TRANSACTION_RETRY_LOG_FILE', 'database/transaction-retries'),
+
+ 'logging' => [
+ 'channel' => env('DB_TRANSACTION_RETRY_LOG_CHANNEL'),
+
+ 'levels' => [
+ 'success' => env('DB_TRANSACTION_RETRY_LOG_SUCCESS_LEVEL', 'warning'),
+ 'failure' => env('DB_TRANSACTION_RETRY_LOG_FAILURE_LEVEL', 'error'),
+ ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Retryable Exceptions
+ |--------------------------------------------------------------------------
+ |
+ | Configure the database errors that should trigger a retry. SQLSTATE codes
+ | and driver error codes are checked for `QueryException` instances. You may
+ | also list additional exception classes to retry on by name.
+ |
+ */
+
+ 'retryable_exceptions' => [
+ 'sql_states' => [
+ '40001', // Serialization failure
+ ],
+
+ 'driver_error_codes' => [
+ 1213, // MySQL deadlock
+ // 1205, // MySQL lock wait timeout
+ ],
+
+ 'classes' => [],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Lock Wait Timeout
+ |--------------------------------------------------------------------------
+ |
+ | Optionally override the session-level lock wait timeout before executing
+ | the transaction. When set to a positive integer the helper issues:
+ | "SET SESSION innodb_lock_wait_timeout = {seconds}" on the active
+ | connection prior to each attempt. Set to null to leave the database
+ | default untouched.
+ |
+ */
+
+ 'lock_wait_timeout_seconds' => env('DB_TRANSACTION_RETRY_LOCK_WAIT_TIMEOUT', 50),
+];
diff --git a/config/mysql-deadlock-retry.php b/config/mysql-deadlock-retry.php
deleted file mode 100644
index fbef2d7..0000000
--- a/config/mysql-deadlock-retry.php
+++ /dev/null
@@ -1,45 +0,0 @@
- (int) env('MYSQL_DEADLOCK_MAX_RETRIES', 3),
-
- 'retry_delay' => (int) env('MYSQL_DEADLOCK_RETRY_DELAY', 2),
-
- 'log_file_name' => env('MYSQL_DEADLOCK_LOG_FILE', 'database/mysql-deadlocks'),
-
- /*
- |--------------------------------------------------------------------------
- | Logging
- |--------------------------------------------------------------------------
- |
- | Control how retry attempts are logged. Provide a `channel` to reuse any
- | logging channel defined in your application, supply a `config` array to
- | build a dedicated logger on the fly. When none are defined,
- | the package will continue to emit dated single-file logs per prior
- | behaviour.
- |
- */
-
- 'logging' => [
- 'channel' => env('MYSQL_DEADLOCK_LOG_CHANNEL'),
-
- 'config' => null,
-
- 'levels' => [
- 'success' => env('MYSQL_DEADLOCK_LOG_SUCCESS_LEVEL', 'warning'),
- 'failure' => env('MYSQL_DEADLOCK_LOG_FAILURE_LEVEL', 'error'),
- ],
- ],
-];
-
diff --git a/src/Providers/DatabaseRetryServiceProvider.php b/src/Providers/DatabaseRetryServiceProvider.php
deleted file mode 100644
index 544febb..0000000
--- a/src/Providers/DatabaseRetryServiceProvider.php
+++ /dev/null
@@ -1,32 +0,0 @@
-mergeConfigFrom(
- __DIR__ . '/../../config/mysql-deadlock-retry.php',
- 'mysql-deadlock-retry'
- );
- }
-
- /**
- * Bootstrap any package services.
- */
- public function boot(): void
- {
- if ($this->app->runningInConsole()) {
- $configPath = function_exists('config_path')
- ? config_path('mysql-deadlock-retry.php')
- : $this->app->basePath('config/mysql-deadlock-retry.php');
-
- $this->publishes([
- __DIR__ . '/../../config/mysql-deadlock-retry.php' => $configPath,
- ], 'mysql-deadlock-retry-config');
- }
- }
-}
diff --git a/src/Providers/DatabaseTransactionRetryServiceProvider.php b/src/Providers/DatabaseTransactionRetryServiceProvider.php
new file mode 100644
index 0000000..e4791b5
--- /dev/null
+++ b/src/Providers/DatabaseTransactionRetryServiceProvider.php
@@ -0,0 +1,32 @@
+mergeConfigFrom(
+ __DIR__ . '/../../config/database-transaction-retry.php',
+ 'database-transaction-retry'
+ );
+ }
+
+ /**
+ * Bootstrap any package services.
+ */
+ public function boot(): void
+ {
+ if ($this->app->runningInConsole()) {
+ $configPath = function_exists('config_path')
+ ? config_path('database-transaction-retry.php')
+ : $this->app->basePath('config/database-transaction-retry.php');
+
+ $this->publishes([
+ __DIR__ . '/../../config/database-transaction-retry.php' => $configPath,
+ ], 'database-transaction-retry-config');
+ }
+ }
+}
diff --git a/src/Services/DeadlockTransactionRetrier.php b/src/Services/DeadlockTransactionRetrier.php
deleted file mode 100644
index 7d7a392..0000000
--- a/src/Services/DeadlockTransactionRetrier.php
+++ /dev/null
@@ -1,244 +0,0 @@
-instance('tx.label', $trxLabel);
-
- return DB::transaction($callback);
- } catch (QueryException $e) {
- $exceptionCaught = true;
- $shouldRetryError = static::shouldRetry($e);
-
- if ($shouldRetryError) {
- $attempt++;
- $logEntries[] = static::makeRetryContext($e, $attempt, $maxRetries, $trxLabel);
-
- if ($attempt >= $maxRetries) {
- $throwable = $e;
- } else {
- static::pause(static::nextBackoffInterval($retryDelay, $attempt));
- continue;
- }
- } else {
- $throwable = $e;
- }
- } finally {
- static::logOutcome(
- $logEntries,
- $logFileName,
- $throwable,
- $exceptionCaught,
- $shouldRetryError
- );
-
- if (! is_null($throwable)) {
- throw $throwable;
- }
- }
- }
-
- throw new RuntimeException('Transaction with retry exhausted after ' . $maxRetries . ' attempts.');
- }
-
- protected static function shouldRetry(QueryException $e): bool
- {
- return static::isDeadlock($e) || static::isSerializationFailure($e);
- }
-
- protected static function isDeadlock(QueryException $e): bool
- {
- $driverErr = is_array($e->errorInfo ?? null) && isset($e->errorInfo[1]) ? $e->errorInfo[1] : null;
- $sqlState = $e->getCode();
-
- return (int) $driverErr === 1213 || (int) $sqlState === 1213;
- }
-
- protected static function isSerializationFailure(QueryException $e): bool
- {
- return $e->getCode() === '40001';
- }
-
- protected static function makeRetryContext(QueryException $e, int $attempt, int $maxRetries, string $trxLabel): array
- {
- $sql = method_exists($e, 'getSql') ? $e->getSql() : null;
- $bindings = method_exists($e, 'getBindings') ? $e->getBindings() : [];
-
- $connectionName = $e->getConnectionName();
- $conn = DB::connection($connectionName);
-
- $rawSql = method_exists($e, 'getRawSql') ? $e->getRawSql() : null;
- if (is_null($rawSql) && ! is_null($sql) && ! empty($bindings)) {
- $rawSql = $conn->getQueryGrammar()->substituteBindingsIntoRawSql($sql, $bindings);
- }
-
- $requestData = [
- 'url' => null,
- 'method' => null,
- 'token' => null,
- 'userId' => null,
- ];
-
- try {
- if (function_exists('request') && app()->bound('request')) {
- $req = request();
- $requestData['url'] = method_exists($req, 'getUri') ? $req->getUri() : null;
- $requestData['method'] = method_exists($req, 'getMethod') ? $req->getMethod() : null;
- if (method_exists($req, 'header')) {
- $auth = $req->header('authorization');
- $requestData['authHeaderLen'] = $auth ? strlen($auth) : null;
- }
- $requestData['userId'] = method_exists($req, 'user') && $req->user()
- ? ($req->user()->id ?? null)
- : null;
- }
- } catch (Throwable) {
- // ignore
- }
-
- return array_merge($requestData, [
- 'attempt' => $attempt,
- 'maxRetries' => $maxRetries,
- 'trxLabel' => $trxLabel,
- 'errorInfo' => $e->errorInfo,
- 'rawSql' => $rawSql,
- 'connection' => $connectionName,
- 'trace' => TraceFormatter::snapshot(),
- ]);
- }
-
- /**
- * @throws RandomException
- */
- protected static function nextBackoffInterval(int $baseDelay, int $attempt): int
- {
- $delay = max(1, (int) round($baseDelay * pow(2, max(0, $attempt - 1))));
- $jitter = max(0, (int) round($delay * 0.25));
- $min = max(1, $delay - $jitter);
- $max = $delay + $jitter;
-
- return random_int($min, $max);
- }
-
- protected static function logOutcome(
- array $logEntries,
- string $logFileName,
- ?Throwable $throwable,
- bool $exceptionCaught,
- bool $shouldRetryError
- ): void {
- $levels = static::configuredLogLevels();
-
- if (is_null($throwable) && ! $exceptionCaught) {
- if (count($logEntries) > 0) {
- $entry = $logEntries[count($logEntries) - 1];
- $entry['retryStatus'] = 'success';
-
- DeadlockLogWriter::write($entry, $logFileName, $levels['success']);
- }
-
- return;
- }
-
- if (! is_null($throwable) && $shouldRetryError && count($logEntries) > 0) {
- $entry = $logEntries[count($logEntries) - 1];
- $entry['retryStatus'] = 'failure';
-
- DeadlockLogWriter::write($entry, $logFileName, $levels['failure']);
- }
-
- // Non-retryable errors rethrow outside this helper; only log when retries are exhausted.
- }
-
- protected static function pause(int $seconds): void
- {
- $overriddenSleep = 'MysqlDeadlocks\\RetryHelper\\sleep';
-
- if (function_exists($overriddenSleep)) {
- $overriddenSleep($seconds);
-
- return;
- }
-
- sleep($seconds);
- }
-
- protected static function configuredLogLevels(): array
- {
- $defaults = [
- 'success' => 'warning',
- 'failure' => 'error',
- ];
-
- if (! function_exists('config')) {
- return $defaults;
- }
-
- $levels = config('mysql-deadlock-retry.logging.levels', []);
-
- if (! is_array($levels)) {
- return $defaults;
- }
-
- return [
- 'success' => static::normaliseLogLevel($levels['success'] ?? null, $defaults['success']),
- 'failure' => static::normaliseLogLevel($levels['failure'] ?? null, $defaults['failure']),
- ];
- }
-
- protected static function normaliseLogLevel(?string $level, string $fallback): string
- {
- $candidate = is_string($level) ? strtolower(trim($level)) : null;
-
- return $candidate !== '' ? $candidate : $fallback;
- }
-}
diff --git a/src/Services/TransactionRetrier.php b/src/Services/TransactionRetrier.php
new file mode 100644
index 0000000..f15033c
--- /dev/null
+++ b/src/Services/TransactionRetrier.php
@@ -0,0 +1,343 @@
+instance('tx.label', $trxLabel);
+
+ return DB::transaction($callback);
+ } catch (Throwable $exception) {
+ $exceptionCaught = true;
+ $shouldRetryError = static::shouldRetry($exception);
+
+ if ($shouldRetryError) {
+ $attempt++;
+ $logEntries[] = static::makeRetryContext($exception, $attempt, $maxRetries, $trxLabel);
+
+ if ($attempt >= $maxRetries) {
+ $throwable = $exception;
+ } else {
+ static::pause(static::nextBackoffInterval($retryDelay, $attempt));
+ continue;
+ }
+ } else {
+ $throwable = $exception;
+ }
+ } finally {
+ static::logOutcome(
+ $logEntries,
+ $logFileName,
+ $throwable,
+ $exceptionCaught,
+ $shouldRetryError
+ );
+
+ if (! is_null($throwable)) {
+ throw $throwable;
+ }
+ }
+ }
+
+ throw new RuntimeException('Transaction with retry exhausted after ' . $maxRetries . ' attempts.');
+ }
+
+ protected static function shouldRetry(Throwable $throwable): bool
+ {
+ $config = function_exists('config') ? config('database-transaction-retry.retryable_exceptions', []) : [];
+
+ if (! is_array($config)) {
+ $config = [];
+ }
+
+ $retryableClasses = array_filter(
+ array_map('trim', is_array($config['classes'] ?? null) ? $config['classes'] : []),
+ static fn ($class) => $class !== ''
+ );
+
+ foreach ($retryableClasses as $class) {
+ if (class_exists($class) && $throwable instanceof $class) {
+ return true;
+ }
+ }
+
+ if ($throwable instanceof QueryException) {
+ return static::isRetryableQueryException($throwable, $config);
+ }
+
+ return false;
+ }
+
+ protected static function isRetryableQueryException(QueryException $exception, array $config): bool
+ {
+ $sqlStates = is_array($config['sql_states'] ?? null) ? $config['sql_states'] : [];
+ $sqlStates = array_map(static fn ($state) => strtoupper((string) $state), $sqlStates);
+
+ $driverCodes = is_array($config['driver_error_codes'] ?? null) ? $config['driver_error_codes'] : [];
+ $driverCodes = array_map(static fn ($code) => (int) $code, $driverCodes);
+
+ $sqlState = strtoupper((string) $exception->getCode());
+ $driverErr = is_array($exception->errorInfo ?? null) && isset($exception->errorInfo[1])
+ ? (int) $exception->errorInfo[1]
+ : null;
+
+ if (in_array($sqlState, $sqlStates, true)) {
+ return true;
+ }
+
+ if (! is_null($driverErr) && in_array($driverErr, $driverCodes, true)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ protected static function makeRetryContext(Throwable $throwable, int $attempt, int $maxRetries, string $trxLabel): array
+ {
+ $context = [
+ 'attempt' => $attempt,
+ 'maxRetries' => $maxRetries,
+ 'trxLabel' => $trxLabel,
+ 'exceptionClass' => get_class($throwable),
+ ];
+
+ if ($throwable instanceof QueryException) {
+ $sql = method_exists($throwable, 'getSql') ? $throwable->getSql() : null;
+ $bindings = method_exists($throwable, 'getBindings') ? $throwable->getBindings() : [];
+
+ $connectionName = $throwable->getConnectionName();
+ $context['connection'] = $connectionName;
+
+ $conn = DB::connection($connectionName);
+
+ $rawSql = method_exists($throwable, 'getRawSql') ? $throwable->getRawSql() : null;
+ if (is_null($rawSql) && ! is_null($sql) && ! empty($bindings)) {
+ $rawSql = $conn->getQueryGrammar()->substituteBindingsIntoRawSql($sql, $bindings);
+ }
+
+ $context['rawSql'] = $rawSql;
+ $context['errorInfo'] = $throwable->errorInfo;
+ $context['sqlState'] = isset($throwable->errorInfo[0])
+ ? (string) $throwable->errorInfo[0]
+ : (method_exists($throwable, 'getCode') ? (string) $throwable->getCode() : null);
+ $context['driverCode'] = isset($throwable->errorInfo[1]) ? (int) $throwable->errorInfo[1] : null;
+ }
+
+ $context['trace'] = TraceFormatter::snapshot();
+
+ try {
+ $context += static::requestSnapshot();
+ } catch (Throwable) {
+ // ignore
+ }
+
+ return $context;
+ }
+
+ protected static function requestSnapshot(): array
+ {
+ $data = [
+ 'url' => null,
+ 'method' => null,
+ 'token' => null,
+ 'userId' => null,
+ ];
+
+ if (! function_exists('request') || ! app()->bound('request')) {
+ return $data;
+ }
+
+ $request = request();
+
+ $data['url'] = method_exists($request, 'getUri') ? $request->getUri() : null;
+ $data['method'] = method_exists($request, 'getMethod') ? $request->getMethod() : null;
+
+ if (method_exists($request, 'header')) {
+ $auth = $request->header('authorization');
+ $data['authHeaderLen'] = $auth ? strlen($auth) : null;
+ }
+
+ $data['userId'] = method_exists($request, 'user') && $request->user()
+ ? ($request->user()->id ?? null)
+ : null;
+
+ return $data;
+ }
+
+ /**
+ * @throws RandomException
+ */
+ protected static function nextBackoffInterval(int $baseDelay, int $attempt): int
+ {
+ $delay = max(1, (int) round($baseDelay * pow(2, max(0, $attempt - 1))));
+ $jitter = max(0, (int) round($delay * 0.25));
+ $min = max(1, $delay - $jitter);
+ $max = $delay + $jitter;
+
+ return random_int($min, $max);
+ }
+
+ protected static function logOutcome(
+ array $logEntries,
+ string $logFileName,
+ ?Throwable $throwable,
+ bool $exceptionCaught,
+ bool $shouldRetryError
+ ): void {
+ $levels = static::configuredLogLevels();
+
+ if (is_null($throwable) && ! $exceptionCaught) {
+ if (count($logEntries) > 0) {
+ $entry = $logEntries[count($logEntries) - 1];
+ $entry['retryStatus'] = 'success';
+
+ TransactionRetryLogWriter::write($entry, $logFileName, $levels['success']);
+ }
+
+ return;
+ }
+
+ if (! is_null($throwable) && $shouldRetryError && count($logEntries) > 0) {
+ $entry = $logEntries[count($logEntries) - 1];
+ $entry['retryStatus'] = 'failure';
+
+ TransactionRetryLogWriter::write($entry, $logFileName, $levels['failure']);
+ }
+
+ // Non-retryable errors rethrow outside this helper; only log when retries are exhausted.
+ }
+
+ protected static function pause(int $seconds): void
+ {
+ $overriddenSleep = 'DatabaseTransactions\\RetryHelper\\sleep';
+
+ if (function_exists($overriddenSleep)) {
+ $overriddenSleep($seconds);
+
+ return;
+ }
+
+ sleep($seconds);
+ }
+
+ protected static function configuredLogLevels(): array
+ {
+ $defaults = [
+ 'success' => 'warning',
+ 'failure' => 'error',
+ ];
+
+ if (! function_exists('config')) {
+ return $defaults;
+ }
+
+ $levels = config('database-transaction-retry.logging.levels', []);
+
+ if (! is_array($levels)) {
+ return $defaults;
+ }
+
+ return [
+ 'success' => static::normalizeLogLevel($levels['success'] ?? null, $defaults['success']),
+ 'failure' => static::normalizeLogLevel($levels['failure'] ?? null, $defaults['failure']),
+ ];
+ }
+
+ protected static function normalizeLogLevel(?string $level, string $fallback): string
+ {
+ $candidate = is_string($level) ? strtolower(trim($level)) : null;
+
+ return $candidate !== '' ? $candidate : $fallback;
+ }
+
+ protected static function applyLockWaitTimeout(array $config): void
+ {
+ $seconds = $config['lock_wait_timeout_seconds'] ?? null;
+
+ if (! static::isLockWaitRetryEnabled($config)) {
+ return;
+ }
+
+ if (is_null($seconds)) {
+ return;
+ }
+
+ if (is_string($seconds) && $seconds === '') {
+ return;
+ }
+
+ $seconds = (int) $seconds;
+
+ if ($seconds < 1) {
+ return;
+ }
+
+ try {
+ DB::statement('SET SESSION innodb_lock_wait_timeout = ?', [$seconds]);
+ } catch (Throwable) {
+ // Silently ignore when the underlying driver does not support this option.
+ }
+ }
+
+ protected static function isLockWaitRetryEnabled(array $config): bool
+ {
+ $retryable = is_array($config['retryable_exceptions'] ?? null)
+ ? $config['retryable_exceptions']
+ : [];
+
+ $driverCodes = is_array($retryable['driver_error_codes'] ?? null)
+ ? array_map(static fn ($code) => (int) $code, $retryable['driver_error_codes'])
+ : [];
+
+ return in_array(1205, $driverCodes, true);
+ }
+}
diff --git a/src/Support/BindingStringifier.php b/src/Support/BindingStringifier.php
index bc268b5..45274b2 100644
--- a/src/Support/BindingStringifier.php
+++ b/src/Support/BindingStringifier.php
@@ -1,6 +1,6 @@
format('Y-m-d H:i:s.u');
}
+
if (is_object($binding)) {
return '[object ' . get_class($binding) . ']';
}
+
if (is_resource($binding)) {
return '[resource]';
}
+
if (is_string($binding)) {
return mb_strlen($binding) > 500
? (mb_substr($binding, 0, 500) . '…[+trimmed]')
: $binding;
}
+
if (is_array($binding)) {
$json = @json_encode($binding, JSON_UNESCAPED_UNICODE);
diff --git a/src/Support/TraceFormatter.php b/src/Support/TraceFormatter.php
index ce75ade..a308daf 100644
--- a/src/Support/TraceFormatter.php
+++ b/src/Support/TraceFormatter.php
@@ -1,6 +1,6 @@
(string) $payload];
- $attempts = $context['attempt'] ?? 0;
- $max = $context['maxRetries'] ?? 0;
- $label = $context['trxLabel'] ?? '';
+ $attempts = (int) ($context['attempt'] ?? 0);
+ $max = (int) ($context['maxRetries'] ?? 0);
+ $label = (string) ($context['trxLabel'] ?? '');
$normalizedLevel = static::normalizeLevel($level, $levels['failure']);
$status = strtolower((string) ($context['retryStatus'] ?? ($normalizedLevel === $levels['success'] ? 'success' : 'failure')));
$statusLabel = strtoupper($status === 'success' ? 'SUCCESS' : 'FAILED');
+ $exceptionClass = (string) ($context['exceptionClass'] ?? 'UnknownException');
+ $sqlState = (string) ($context['sqlState'] ?? '');
+ $driverCode = $context['driverCode'] ?? null;
+
+ $codeParts = [];
+ $sqlState !== '' && $codeParts[] = 'SQLSTATE ' . $sqlState;
+ ! is_null($driverCode) && $codeParts[] = 'Driver ' . $driverCode;
+
+ $exceptionSummary = trim($exceptionClass . (count($codeParts) > 0 ? ' (' . implode(', ', $codeParts) . ')' : ''));
+
$title = sprintf(
- '[%s] [MYSQL DEADLOCK RETRY - %s] After (Attempts: %d/%d) - %s',
+ '[%s] [DATABASE TRANSACTION RETRY - %s] %s After (Attempts: %d/%d) - %s',
$label,
$statusLabel,
+ $exceptionSummary,
$attempts,
$max,
ucfirst($normalizedLevel)
@@ -40,7 +51,7 @@ protected static function resolveLogger(string $logFileName): LoggerInterface
$logging = [];
if (function_exists('config')) {
- $config = config('mysql-deadlock-retry.logging', []);
+ $config = config('database-transaction-retry.logging', []);
if (is_array($config)) {
$logging = $config;
}
@@ -102,7 +113,7 @@ protected static function configuredLevels(): array
return $defaults;
}
- $levels = config('mysql-deadlock-retry.logging.levels', []);
+ $levels = config('database-transaction-retry.logging.levels', []);
if (! is_array($levels)) {
return $defaults;
diff --git a/tests/TestCase.php b/tests/TestCase.php
index f5a8971..ebac46d 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -25,8 +25,8 @@ protected function setUp(): void
$configRepository = new Repository();
$configRepository->set(
- 'mysql-deadlock-retry',
- require dirname(__DIR__) . '/config/mysql-deadlock-retry.php'
+ 'database-transaction-retry',
+ require dirname(__DIR__) . '/config/database-transaction-retry.php'
);
$this->app->instance('config', $configRepository);
@@ -47,7 +47,7 @@ final class TestApplication extends Container
{
public function storagePath($path = ''): string
{
- $base = sys_get_temp_dir() . '/laravel-mysql-deadlock-retry/storage';
+ $base = sys_get_temp_dir() . '/laravel-db-transaction-retry/storage';
return $path === '' ? $base : $base . '/' . ltrim((string) $path, '/');
}
diff --git a/tests/Unit/DBTransactionRetryHelperTest.php b/tests/Unit/DBTransactionRetryHelperTest.php
index e761348..3495ef4 100644
--- a/tests/Unit/DBTransactionRetryHelperTest.php
+++ b/tests/Unit/DBTransactionRetryHelperTest.php
@@ -1,6 +1,6 @@
'done');
+ $result = TransactionRetrier::runWithRetry(fn () => 'done');
expect($result)->toBe('done');
expect($this->database->transactionCalls)->toBe(1);
@@ -35,7 +36,7 @@ function sleep(int $seconds): void
test('retries on deadlock and logs warning', function (): void {
$attempts = 0;
- $result = DeadlockTransactionRetrier::runWithRetry(function () use (&$attempts) {
+ $result = TransactionRetrier::runWithRetry(function () use (&$attempts) {
$attempts++;
if ($attempts === 1) {
@@ -54,15 +55,18 @@ function sleep(int $seconds): void
$record = $this->logManager->records[0];
expect($record['level'])->toBe('warning');
- expect($record['message'])->toBe('[orders] [MYSQL DEADLOCK RETRY - SUCCESS] After (Attempts: 1/3) - Warning');
+ expect($record['message'])->toBe('[orders] [DATABASE TRANSACTION RETRY - SUCCESS] Illuminate\Database\QueryException (SQLSTATE 40001, Driver 1213) After (Attempts: 1/3) - Warning');
expect($record['context']['attempt'])->toBe(1);
expect($record['context']['maxRetries'])->toBe(3);
expect($record['context']['trxLabel'])->toBe('orders');
+ expect($record['context']['exceptionClass'])->toBe(QueryException::class);
+ expect($record['context']['sqlState'])->toBe('40001');
+ expect($record['context']['driverCode'])->toBe(1213);
});
test('throws after max retries and logs error', function (): void {
try {
- DeadlockTransactionRetrier::runWithRetry(function (): void {
+ TransactionRetrier::runWithRetry(function (): void {
throw makeQueryException(1213);
}, maxRetries: 3, retryDelay: 1, trxLabel: 'payments');
@@ -81,15 +85,18 @@ function sleep(int $seconds): void
$record = $this->logManager->records[0];
expect($record['level'])->toBe('error');
- expect($record['message'])->toBe('[payments] [MYSQL DEADLOCK RETRY - FAILED] After (Attempts: 3/3) - Error');
+ expect($record['message'])->toBe('[payments] [DATABASE TRANSACTION RETRY - FAILED] Illuminate\Database\QueryException (SQLSTATE 40001, Driver 1213) After (Attempts: 3/3) - Error');
expect($record['context']['attempt'])->toBe(3);
expect($record['context']['maxRetries'])->toBe(3);
expect($record['context']['trxLabel'])->toBe('payments');
+ expect($record['context']['exceptionClass'])->toBe(QueryException::class);
+ expect($record['context']['sqlState'])->toBe('40001');
+ expect($record['context']['driverCode'])->toBe(1213);
});
test('does not retry for non deadlock query exception', function (): void {
try {
- DeadlockTransactionRetrier::runWithRetry(function (): void {
+ TransactionRetrier::runWithRetry(function (): void {
throw makeQueryException(999, 0);
}, maxRetries: 3, retryDelay: 1);
@@ -103,10 +110,143 @@ function sleep(int $seconds): void
expect(SleepSpy::$delays)->toBe([]);
});
-function makeQueryException(int $driverCode, int $sqlState = 40001): QueryException
+test('retries on lock wait timeout and applies configured session timeout', function (): void {
+ Container::getInstance()->make('config')->set(
+ 'database-transaction-retry.lock_wait_timeout_seconds',
+ 7
+ );
+
+ Container::getInstance()->make('config')->set(
+ 'database-transaction-retry.retryable_exceptions.driver_error_codes',
+ [1205]
+ );
+
+ $attempts = 0;
+
+ $result = TransactionRetrier::runWithRetry(function () use (&$attempts) {
+ $attempts++;
+
+ if ($attempts === 1) {
+ throw makeQueryException(1205, 'HY000');
+ }
+
+ return 'done';
+ }, maxRetries: 3, retryDelay: 1, trxLabel: 'lock-wait');
+
+ expect($result)->toBe('done');
+ expect($this->database->transactionCalls)->toBe(2);
+ expect(SleepSpy::$delays)->toHaveCount(1);
+ expect($this->database->statementCalls)->toHaveCount(2);
+
+ [$statement, $bindings] = $this->database->statementCalls[0];
+ expect($statement)->toBe('SET SESSION innodb_lock_wait_timeout = ?');
+ expect($bindings)->toBe([7]);
+
+ $record = $this->logManager->records[0];
+ expect($record['context']['driverCode'])->toBe(1205);
+ expect($record['context']['sqlState'])->toBe('HY000');
+ expect($record['message'])->toContain('Driver 1205');
+ expect($record['message'])->toContain('SQLSTATE HY000');
+});
+
+test('does not change session timeout when lock wait retry disabled', function (): void {
+ Container::getInstance()->make('config')->set(
+ 'database-transaction-retry.lock_wait_timeout_seconds',
+ 9
+ );
+
+ Container::getInstance()->make('config')->set(
+ 'database-transaction-retry.retryable_exceptions.driver_error_codes',
+ [1213]
+ );
+
+ Container::getInstance()->make('config')->set(
+ 'database-transaction-retry.retryable_exceptions.sql_states',
+ []
+ );
+
+ try {
+ TransactionRetrier::runWithRetry(function (): void {
+ throw makeQueryException(1205, 'HY000');
+ }, maxRetries: 2, retryDelay: 1);
+
+ $this->fail('Expected QueryException was not thrown.');
+ } catch (QueryException $th) {
+ expect($th->errorInfo[1])->toBe(1205);
+ }
+
+ expect($this->database->statementCalls)->toBe([]);
+});
+
+test('retries when driver code is configured', function (): void {
+ Container::getInstance()->make('config')->set(
+ 'database-transaction-retry.retryable_exceptions.driver_error_codes',
+ [1213, 999]
+ );
+
+ $attempts = 0;
+
+ $result = TransactionRetrier::runWithRetry(function () use (&$attempts) {
+ $attempts++;
+
+ if ($attempts === 1) {
+ throw makeQueryException(999, 0);
+ }
+
+ return 'recovered';
+ }, maxRetries: 3, retryDelay: 1, trxLabel: 'invoices');
+
+ expect($result)->toBe('recovered');
+ expect($this->database->transactionCalls)->toBe(2);
+ expect($this->logManager->records)->toHaveCount(1);
+ $record = $this->logManager->records[0];
+
+ expect($record['message'])->toBe('[invoices] [DATABASE TRANSACTION RETRY - SUCCESS] Illuminate\Database\QueryException (SQLSTATE 00000, Driver 999) After (Attempts: 1/3) - Warning');
+ expect($record['context']['driverCode'])->toBe(999);
+ expect($record['context']['sqlState'])->toBe('00000');
+});
+
+test('retries when exception class is configured', function (): void {
+ Container::getInstance()->make('config')->set(
+ 'database-transaction-retry.retryable_exceptions.classes',
+ [CustomRetryException::class]
+ );
+
+ $attempts = 0;
+
+ $result = TransactionRetrier::runWithRetry(function () use (&$attempts) {
+ $attempts++;
+
+ if ($attempts === 1) {
+ throw new CustomRetryException('try again');
+ }
+
+ return 'ok';
+ }, maxRetries: 3, retryDelay: 1, trxLabel: 'custom');
+
+ expect($result)->toBe('ok');
+ expect($this->database->transactionCalls)->toBe(2);
+
+ $record = $this->logManager->records[0];
+
+ expect($record['message'])->toBe('[custom] [DATABASE TRANSACTION RETRY - SUCCESS] Tests\\CustomRetryException After (Attempts: 1/3) - Warning');
+ expect($record['context']['exceptionClass'])->toBe(CustomRetryException::class);
+ expect(array_key_exists('driverCode', $record['context']))->toBeFalse();
+ expect(array_key_exists('sqlState', $record['context']))->toBeFalse();
+});
+
+function makeQueryException(int $driverCode, string|int $sqlState = 40001): QueryException
{
- $sqlStateString = str_pad((string) $sqlState, 5, '0', STR_PAD_LEFT);
- $pdo = new \PDOException('SQLSTATE[' . $sqlStateString . ']: Driver error', $sqlState);
+ $sqlStateString = strtoupper((string) $sqlState);
+
+ if (strlen($sqlStateString) < 5) {
+ $sqlStateString = str_pad($sqlStateString, 5, '0', STR_PAD_LEFT);
+ }
+
+ $pdo = new \PDOException(
+ 'SQLSTATE[' . $sqlStateString . ']: Driver error',
+ is_numeric($sqlState) ? (int) $sqlState : 0
+ );
$pdo->errorInfo = [$sqlStateString, $driverCode, 'Driver error'];
return new QueryException(
@@ -117,9 +257,15 @@ function makeQueryException(int $driverCode, int $sqlState = 40001): QueryExcept
);
}
+final class CustomRetryException extends \RuntimeException
+{
+}
+
final class FakeDatabaseManager
{
public int $transactionCalls = 0;
+ /** @var list */
+ public array $statementCalls = [];
private FakeConnection $connection;
public function __construct(?FakeConnection $connection = null)
@@ -138,11 +284,25 @@ public function connection(?string $name = null): FakeConnection
{
return $this->connection;
}
+
+ public function statement(string $query, array $bindings = []): bool
+ {
+ $this->statementCalls[] = [$query, $bindings];
+
+ return $this->connection()->statement($query, $bindings);
+ }
+
+ public function getConnection(): FakeConnection
+ {
+ return $this->connection;
+ }
}
final class FakeConnection
{
private FakeQueryGrammar $grammar;
+ /** @var list */
+ public array $statements = [];
public function __construct(?FakeQueryGrammar $grammar = null)
{
@@ -153,6 +313,13 @@ public function getQueryGrammar(): FakeQueryGrammar
{
return $this->grammar;
}
+
+ public function statement(string $query, array $bindings = []): bool
+ {
+ $this->statements[] = [$query, $bindings];
+
+ return true;
+ }
}
final class FakeQueryGrammar
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index bcd6ff2..e63cc8d 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -7,7 +7,7 @@
if (! function_exists('storage_path')) {
function storage_path(string $path = ''): string
{
- $base = sys_get_temp_dir() . '/laravel-mysql-deadlock-retry';
+ $base = sys_get_temp_dir() . '/laravel-db-transaction-retry';
return $path === '' ? $base : $base . '/' . ltrim($path, '/');
}