abdulsalam / laravel-operation-preview
Safe dry-run / operation preview runtime for Laravel admin and critical operations.
Package info
github.com/abdulsalamalkhatib96/laravel-operation-preview
pkg:composer/abdulsalam/laravel-operation-preview
Requires
- php: ^8.2
- illuminate/cache: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/filesystem: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/mail: ^12.0|^13.0
- illuminate/notifications: ^12.0|^13.0
- illuminate/process: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A dry-run runtime for sensitive Laravel operations. It executes your real application code inside a controlled preview session, rolls back transactional database state, intercepts supported external side effects, records an effect graph-friendly event stream, and reports where it cannot guarantee safety.
Important: this package does not claim that arbitrary PHP can be simulated with zero side effects. Laravel-level integrations can be controlled; direct
curl_exec, native process functions, manually constructed SDK clients, explicit outercommit(), and other escape hatches require adapters or an isolated environment.
Requirements
- PHP 8.2+
- Laravel 12 or 13
Install
composer require abdulsalam/laravel-operation-preview php artisan vendor:publish --tag=operation-preview-config
Laravel package discovery registers the provider and Operation facade automatically.
Basic preview
use Abdulsalam\OperationPreview\Facades\Operation; $preview = Operation::preview(function () use ($player) { app(PlayerService::class)->deletePlayer($player); }); return $preview->toArray();
Named, watched operation:
$preview = Operation::named('player.delete') ->subject($player) ->watch([$player, $player->wallet]) ->trackTables(['player_sessions', 'player_bonuses']) ->scenario( PreviewScenario::make()->http( 'POST', 'https://casino.example.com/*', status: 200, body: ['success' => true], ) ) ->preview(fn () => app(PlayerService::class)->deletePlayer($player));
Result shape
{
"status": "partial",
"guarantee": "transactionally_rolled_back",
"coverage": {
"overall": "partial",
"components": {
"database": "partial",
"events": "full",
"bus": "full",
"queue": "full",
"http": "full",
"mail": "full",
"notifications": "full",
"process": "full"
}
},
"effects": []
}
Effects have id, type, label, phase, optional parent_id, payload data and (optionally) origin source metadata. By default, coverage is intentionally partial until you explicitly attest that the previewed path has no unmanaged direct I/O and no unsafe manual outer commit.
Database model
Transaction mode:
- opens a preview-owned transaction on every configured connection;
- executes the real business logic;
- observes SQL and Eloquent changes;
- snapshots explicitly tracked tables if requested;
- always rolls back in
finally.
DDL such as DROP, ALTER, TRUNCATE, CREATE, GRANT, and SET GLOBAL is blocked while preview is active.
Explicit commit() limitation
Laravel does not expose a supported way for a package to override Connection::commit() on arbitrary existing connections. If previewed code explicitly commits the outer preview transaction, transaction isolation can be escaped. operation-preview:scan flags direct transaction boundaries and the runtime verifies that its transaction floor still exists after the operation. Set database.assume_no_manual_commit=true only after auditing the code path. For truly hostile/arbitrary code, execute the application against a disposable database/environment instead.
Events
The package does not use Event::fake() for the preview runtime. Instead it wraps the real dispatcher:
- dispatch is recorded;
- synchronous listeners still run, so their database effects are included;
- queued listeners flow into the queue interceptor;
ShouldDispatchAfterCommitis labelledafter_commitand remains subject to Laravel's real transaction semantics.
Queue
Laravel's queue fake is installed only for the lifetime of the preview and restored in finally. Pushed jobs, connection, queue, delay, chain and after-commit metadata are recorded. dispatchSync() remains synchronous and therefore its real handler effects are observed. A dedicated Bus wrapper blocks and records dispatchAfterResponse() so no terminating callback survives the preview.
HTTP scenarios
No real request through Laravel's HTTP client is allowed while previewing.
$scenario = PreviewScenario::make() ->http('POST', 'https://provider.test/*', 200, ['ok' => true]);
In strict mode, an unmatched request aborts the preview instead of inventing a response.
Direct Guzzle/cURL/socket usage is outside the Laravel HTTP client and requires an application-specific interceptor.
Mail, notifications and processes
Laravel Mail and Notifications are replaced with framework fakes during preview. Laravel Process is replaced with a fake factory. All are restored even if the operation throws.
Semantic effects
Automatic technical effects are useful for engineers. Admin UIs often need business language:
Operation::impact('player.bonuses.cancel', ['count' => 2]);
This creates a semantic effect you can translate/render as "Cancel 2 active bonuses".
Preview → confirm
If confirmation is enabled, a complete preview returns a signed, short-lived token. Partial previews do not receive a confirmation token unless confirmation.allow_partial=true is explicitly configured. Watched Eloquent models are fingerprinted.
$result = Operation::named('player.delete') ->watch([$player, $player->wallet]) ->preview(fn () => $service->deletePlayer($player));
Later:
Operation::confirm($request->string('token')->toString(), function () use ($player, $service) { return $service->deletePlayer($player); });
Confirmation is single-use and fails if a watched model changed since preview. Your real operation should still be idempotent because process crashes and transport retries exist outside this token mechanism.
Use the cache preview store in multi-request deployments (default). The array store is suitable only for tests/same-process use.
Safety commands
php artisan operation-preview:doctor php artisan operation-preview:scan
scan looks for known escape hatches including explicit DB commits, raw cURL, native process execution, direct Guzzle construction, native file mutation, Storage/Cache/Redis mutations and immediate broadcasting.
Storage, cache, Redis and broadcasting
These are intentionally not declared fully covered by the built-in runtime because a generic transparent copy-on-write implementation must preserve reads, locks, atomic operations, Lua scripts, streams, temporary URLs, custom filesystem adapters, and driver-specific semantics. Pretending otherwise would make the package unsafe.
For applications using them inside previewed operations, register a custom Interceptor / SideEffectAdapter and tag it as operation-preview.interceptors. The static scanner highlights likely usage.
Example:
final class CasinoGatewayPreviewInterceptor implements Interceptor { public function name(): string { return 'casino'; } public function install(PreviewSession $session): void { /* replace gateway binding */ } public function collect(PreviewSession $session): void { /* record effects */ } public function restore(PreviewSession $session): void { /* restore original */ } }
$this->app->tag([ CasinoGatewayPreviewInterceptor::class, ], 'operation-preview.interceptors');
Enable it in config:
'interceptors' => [ // ... 'casino' => true, ],
Coverage attestation
The default is pessimistic:
'database' => [ 'assume_no_manual_commit' => false, ], 'coverage' => [ 'assume_no_unmanaged_side_effects' => false, ],
Only enable these after running the scanner and reviewing the operation path. This is intentional: a preview package must never manufacture a FULL safety claim.
Production rules
- Do not preview arbitrary user-supplied PHP.
- Keep strict HTTP mode enabled.
- List every database connection a sensitive operation may use.
- Avoid explicit
commit()/rollBack()inside business services; useDB::transaction(). - Add adapters for direct SDKs / storage / Redis / brokers used by the operation.
- Run
operation-preview:scanin CI. - Treat
coverage=partial|noneas a UI blocker for critical operations. - Re-run the real operation after confirmation; never hold a DB transaction open while a human decides.
Testing
composer install
composer test
composer lint
The included Testbench suite covers DB rollback, model/query recording, event listener execution, queue interception, HTTP scenario simulation/strict blocking, semantic effects, and stale confirmation.
License
MIT