fahmiazizhakim / laravel-auto-rollback
Wrap every HTTP request in a database transaction and roll it back afterwards -- for demo, staging, development and dry-run environments.
Package info
github.com/FahmiAzizHakim/laravel-auto-rollback
pkg:composer/fahmiazizhakim/laravel-auto-rollback
Requires
- php: ^8.2
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Run every HTTP request inside a database transaction, then decide once the response has been sent whether to keep it. You can roll back every request, so the database stays exactly as it was, or roll back only the requests that failed.
It is built for demo, staging, development and dry-run environments. In production, ON runs as ON_ERROR, so a successful request is never rolled back there.
Supports MySQL, MariaDB, PostgreSQL and SQLite, on Laravel 11 and 12. Laravel 13 can be installed but has not been tested yet; see Laravel 13.
Installation
composer require fahmiazizhakim/laravel-auto-rollback
The service provider is auto-discovered. Then choose a mode in .env:
AUTO_ROLLBACK=ON
Laravel 13
The package installs on Laravel 13, but it has not been tested there yet. The test suite runs on Laravel 11 and 12.
It relies only on long-standing Laravel APIs (database events, global middleware, terminating callbacks and auth guards), so it is expected to work. Until that is confirmed, try it in development or staging before relying on it.
If you use it on Laravel 13, please let me know how it went, whether it runs smoothly or you hit an error, by opening an issue. For an error, include:
- Your Laravel and PHP versions
- Your database (MySQL, MariaDB, PostgreSQL or SQLite) and its version
- Your
AUTO_ROLLBACKandRBQUERY_LOGsettings - The error message and stack trace
Modes
AUTO_ROLLBACK |
What happens |
|---|---|
ON |
Every request is rolled back |
OFF (default) |
Requests run normally; no transaction is opened (they can still be logged, see ALWAYS) |
ON_ERROR |
Successful requests are committed; requests that failed are rolled back |
Which of these requests get logged is set separately by RBQUERY_LOG. See Query log.
Case does not matter. true and false from v1.0 still mean ON and OFF, and any value the package does not recognise is treated as OFF.
In production
When APP_ENV=production, only OFF and ON_ERROR are available:
AUTO_ROLLBACK |
Runs as |
|---|---|
ON |
ON_ERROR |
OFF |
OFF |
ON_ERROR |
ON_ERROR |
Before using ON_ERROR in production, read the notes on queued jobs and longer transactions under Limits.
In ON_ERROR mode a request failed if either of these is true:
- An exception was thrown during it, including
ValidationException(422),AuthenticationException(401),AuthorizationException(403) andModelNotFoundException(404). - It returned a status of 500 or above without throwing. You can change this threshold with
error_statusin the config, e.g.400to treat every 4xx as a failure.
Configuration
| Variable | Default | Meaning |
|---|---|---|
AUTO_ROLLBACK |
OFF |
ON, OFF or ON_ERROR |
RBQUERY_LOG |
ROLLBACK |
ROLLBACK, ALWAYS or NEVER |
RB_SESSION_USER |
(empty) | The user field to log, e.g. email or id, or header:<name>. See User session log |
AUTO_ROLLBACK_LOG=false from v1.1 still works and means RBQUERY_LOG=NEVER. If both are set, RBQUERY_LOG wins.
To change error_status, the log path, the masked input fields (log.hidden) or the input length limit (log.max_length), publish the config:
php artisan vendor:publish --tag=auto-rollback-config
How it works
- A global middleware, placed first in the stack, starts a transaction on every database connection the request opens. That covers connections opened before the request and connections opened lazily during it.
- Transactions in your own code (
DB::transaction(),beginTransaction()/commit()) become savepoints inside the outer transaction. Nothing reaches the database until the package commits the outer transaction, whichONnever does. - The outer transaction is closed after all terminating callbacks. Writes from
dispatch()->afterResponse()are therefore covered too. - If your code leaves a transaction open,
ON_ERRORdiscards it before committing, the same as Laravel would without the package. - If the final commit fails, the response has already been sent as a success. The failure is therefore reported to your app's normal log through
report(). UnlessRBQUERY_LOG=NEVER, the request is also written to the query log. - Only HTTP requests are affected. Artisan commands, queue workers and the scheduler are left alone.
Query log
RBQUERY_LOG decides which requests have their queries logged:
RBQUERY_LOG |
Logged requests |
|---|---|
ROLLBACK (default) |
Those whose writes were lost: rolled back, or a commit that failed |
ALWAYS |
Every request, whatever happened to it, including under OFF |
NEVER |
None |
How this combines with each mode:
AUTO_ROLLBACK |
ROLLBACK |
ALWAYS |
NEVER |
|---|---|---|---|
ON |
Every request | Every request | Nothing |
ON_ERROR |
Failed requests only | Every request | Nothing |
OFF |
Nothing | Every request | Nothing |
Under OFF + ALWAYS the package only records queries. No transaction is opened, so every write is kept as it would be without the package, including the writes of a request that failed. Under OFF with ROLLBACK or NEVER, the package does not install its middleware at all.
Each logged request's input and queries are appended, with the queries' bindings substituted, to:
storage/logs/autorollback/{Ymd}/{route uri, with / replaced by ~}/{method}.txt
Each route gets one folder, and each method gets one file in it:
storage/logs/autorollback/
└── 20261105/
├── api~v1~website~header/
│ ├── get.txt
│ └── post.txt
├── api~v1~sites~articles/
│ └── post.txt
└── api~v1~sites~articles~{id}/
├── put.txt
└── delete.txt
For example, a POST to Route::post('v1/sites/articles', ...) that failed in ON_ERROR mode is logged to storage/logs/autorollback/20261104/v1~sites~articles/post.txt:
================================================================================
[2026-11-04 10:15:32] POST /v1/sites/articles
Route : v1/sites/articles
Mode : ON_ERROR
Result : ROLLED BACK
Status : 422
IP : 127.0.0.1
User : budi@example.com
Query : {"draft":"1"}
Body : {"title":"","body":"Hello","api_key":"********"}
Files : {"cover":"cover.jpg (84.2 KB, image/jpeg)"}
Error : Illuminate\Validation\ValidationException: The title field is required.
--------------------------------------------------------------------------------
[mysql] (0.84 ms) select * from `users` where `id` = 1 limit 1
[mysql] (1.23 ms) insert into `articles` (`title`) values ('Hello')
--------------------------------------------------------------------------------
[mysql] rolled back
Resultis one of:ROLLED BACKCOMMIT FAILEDCOMMITTED(only logged underALWAYS)NO TRANSACTION(OFF+ALWAYS)
- The section listing each connection's outcome is left out under
OFF, since no transaction was opened. - The
Errorline appears only when the request threw an exception. Usershows who made the request, and appears only whenRB_SESSION_USERis set. See User session log.Query,BodyandFilesrecord the request as it arrived, before middleware such asTrimStringsor your own code changed it. Each line is left out when empty.Queryis the URL's query string. It is present for any method, GET included.Bodyis the parsed JSON or form body. A body Laravel cannot parse, such as XML, plain text or broken JSON, is written asBody : (raw) ....Filesgives each upload's name, size and type, never its contents.- Fields named in
log.hiddenare written as********, at any depth and in any case. By default this coverspassword,token,api_key,secret,pin,otp,cvvand similar fields. A raw body cannot be masked. QueryandBodylonger thanlog.max_lengthcharacters (default 10,000) are cut short.
- The log contains every value your app received, wrote or looked up, including password hashes, tokens and personal data, except the masked fields above. Be careful with
ALWAYSin production, and keepstorage/logsprivate. - The folder name comes from the route's URI pattern, not the request path. Route parameters therefore stay folder segments under their placeholder.
/api/v1/acme/ordersand/api/v1/globex/ordersboth go toapi~v1~{company}~orders/. An optional{period?}becomes{period_}, because?is not allowed in a Windows file name. - The query string is never part of the folder.
/api/v1/website/header?page=2&sort=descgoes toapi~v1~website~header/get.txt, and its parameters are recorded on theQueryline instead. - Requests that reach a
Route::fallback()share one folder, named after Laravel's{fallbackPlaceholder}. - The URI includes any route prefix. Routes in
routes/api.phptherefore log underapi~.... - The separator is
~by default and can be changed withlog.separatorin the config.~is valid on every file system, needs no escaping in URLs, and means nothing to a shell in the middle of a word.#works, but it starts a URL fragment, so links to the file (in an editor or a browser) break at the first#.%works, but it starts URL encoding and Windows CMD variables./ \ < > : " | ? *are refused, and the separator falls back to~.
- A folder name longer than 200 characters is shortened and ends in a short hash, so it stays within file-system limits.
- A request that ran no queries writes nothing.
User session log
RB_SESSION_USER adds a User line to each logged request, saying who sent it:
RB_SESSION_USER=email
[2026-11-05 09:12:04] POST /api/v1/orders
Route : api/v1/orders
Mode : ON
Result : ROLLED BACK
Status : 201
IP : 127.0.0.1
User : budi@example.com
RB_SESSION_USER |
User line |
|---|---|
| Not set, or empty | Left out |
email |
The user's email, or the session value email |
username |
The user's username, or the session value username |
profile.name |
A nested value, reached by dot notation |
header:X-User-Email |
The X-User-Email request header |
The User line is part of the query log, so it is written only for requests that RBQUERY_LOG logs. Under NEVER nothing is written.
From the user or the session
For a field name such as email, the package uses the first of these that has a value:
- The
emailof the authenticated user, from any guard: session, JWT, Sanctum and so on. Only guards that already loaded their user during the request are asked, so no extra query runs. - The session value
email.
When neither has a value, such as for a guest, the line reads User : -.
From a header
RB_SESSION_USER=header:X-User-Email reads that request header, and nothing else: the user and the session are not consulted. Use it for a service behind a gateway, where the gateway passes the user along in a header rather than the service authenticating them.
In a microservice setup, the gateway typically logs the authenticated user and each service logs the header the gateway forwards:
# Gateway RB_SESSION_USER=email # Services behind it RB_SESSION_USER=header:X-User-Email
- Header names are case-insensitive.
- A missing or empty header reads
User : -. - A header is whatever the caller sent. If clients can reach the service directly, bypassing the gateway, they can put any value there.
Masking
- A field named in
log.hidden, such astoken, is written as********. - Credential headers (
Authorization,Proxy-Authorization,Cookie,X-Api-Key,X-CSRF-Token,X-XSRF-Token) and headers named inlog.hiddenare masked too.
Limits
These are properties of database transactions. The package cannot work around them.
- DDL on MySQL/MariaDB (
CREATE,ALTER,DROP,TRUNCATE) commits implicitly. Changes made up to that point, and every write after it in the same request, are kept. The same happens if your code callsDB::commit()without a matchingbeginTransaction(). Either case is reported in the log asNOT ROLLED BACK. PostgreSQL rolls back DDL normally. - MyISAM tables do not support transactions. Use InnoDB.
- Auto-increment counters and sequences are not reset.
- In
ONmode, nothing persists between requests. Anything stored in the database is gone by the next request, including a registration followed by a login, or a session or token kept in a table. - Only the database is rolled back. Cache, Redis, uploaded files, sent mail and calls to other services are not.
- Queued jobs that are pushed to a real queue run in a worker, outside the request.
- In
ONmode they will not find the rows the request created. Jobs and events markedafterCommitnever run, because the transaction never commits. - In
ON_ERRORmode, a job dispatched mid-request can reach a worker before the request commits. Mark such jobsafterCommitso they are pushed only once the request's writes are committed.
- In
- Longer transactions. In
ON_ERRORmode every write stays uncommitted until the request ends, so row locks are held for the whole request rather than just your own transaction. Expect more lock waits under concurrent load. - PostgreSQL aborts the whole transaction on a failed query. If your code catches a query exception and carries on outside
DB::transaction(), the next query fails withcurrent transaction is aborted. - Every service needs the package. In a microservice setup, each service rolls back only its own database.
Testing
composer install
composer test
License
MIT