Search by

abdulsalam / laravel-operation-preview

abdulsalamalkhatib

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

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

V0.1.0 2026-09-11 09:32 UTC

This package is auto-updated.

Last update: 2026-09-11 12:24:28 UTC


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 outer commit(), 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:

  1. opens a preview-owned transaction on every configured connection;
  2. executes the real business logic;
  3. observes SQL and Eloquent changes;
  4. snapshots explicitly tracked tables if requested;
  5. 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;
  • ShouldDispatchAfterCommit is labelled after_commit and 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

  1. Do not preview arbitrary user-supplied PHP.
  2. Keep strict HTTP mode enabled.
  3. List every database connection a sensitive operation may use.
  4. Avoid explicit commit()/rollBack() inside business services; use DB::transaction().
  5. Add adapters for direct SDKs / storage / Redis / brokers used by the operation.
  6. Run operation-preview:scan in CI.
  7. Treat coverage=partial|none as a UI blocker for critical operations.
  8. 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