Search by

directorytree / operations

stevebauman

Run one-time deployment operations in Laravel.

Package info

github.com/DirectoryTree/Operations

pkg:composer/directorytree/operations

Statistics

Installs: 92

Dependents: 0

Suggesters: 0

Stars: 5

Open Issues: 0

v1.0.0 2026-10-02 18:53 UTC

This package is auto-updated.

Last update: 2026-10-02 21:18:32 UTC


README

Run one-time deployment operations in Laravel.

Tests Total Downloads Latest Version License

Installation · Usage · Deployment · Configuration

Operations gives your one-time data changes, backfills, and deployment tasks a place alongside your migrations.

Create a timestamped file in your application's operations directory, write your code in handle(), and run it during deployment. Completed operations are recorded in the operations table and skipped on subsequent runs.

php artisan make:operation backfill_company_names

php artisan operations:run

Requirements

  • PHP 8.2 or higher (PHP 8.3 or higher for Laravel 13)
  • Laravel 12 or 13

Installation

Install the package with Composer:

composer require directorytree/operations

Publish and run the migration:

php artisan vendor:publish --tag=operations-migrations
php artisan migrate

The service provider is registered automatically.

Usage

Creating Operations

php artisan make:operation backfill_company_names

This creates a file such as operations/2026_10_02_120000_backfill_company_names.php:

<?php

use App\Models\Company;
use DirectoryTree\Operations\Operation;
use Illuminate\Console\Command;

return new class extends Operation
{
    public function handle(Command $command): void
    {
        Company::query()
            ->whereNull('display_name')
            ->eachById(function (Company $company) {
                $company->update(['display_name' => $company->name]);
            });
    }
};

Running Operations

Run all pending operations in filename order:

php artisan operations:run

The runner stops when an operation throws an exception. The command fails, the operation stays pending, and later operations are not executed. Running the command again retries unfinished work and skips anything already completed.

The filename without .php is the operation's identity. Keep completed filenames unchanged and create another operation when you need a correction. Operations do not support rollbacks.

Checking Status

php artisan operations:status

View pending and completed operations, including completion timestamps and whether each file is still present. Status checks do not load operation files.

Console Output

Every operation receives the running Artisan command as a required Command $command argument to handle(). Use it to print messages, render tables, and display progress:

use App\Models\Company;
use Illuminate\Console\Command;

public function handle(Command $command): void
{
    $command->info('Backfilling company names...');

    $companies = Company::query()->whereNull('display_name');

    $output = $command->getOutput();
    $output->progressStart($companies->count());

    $companies->chunkById(500, function ($companies) use ($output) {
        foreach ($companies as $company) {
            $company->update(['display_name' => $company->name]);
        }

        $output->progressAdvance($companies->count());
    });

    $output->progressFinish();

    $command->info('Company names updated.');
}

Running this example against 1,500 companies produces the following output:

$ php artisan operations:run

  2026_10_02_120000_backfill_company_names ............................................... RUNNING

Backfilling company names...
    0/1500 [░░░░░░░░░░░░░░░░░░░░░░░░░░░░]   0%
 1000/1500 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓░░░░░░░░░░]  66%
 1500/1500 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

Company names updated.

  2026_10_02_120000_backfill_company_names ............................................ 0.10s DONE

   INFO  Completed 1 operation(s).

In an interactive terminal, the progress bar updates in place. The capture above shows its successive updates; timings and intermediate counts vary with the work being performed.

The runner prints a RUNNING line before each operation and a DONE line with elapsed time after recording completion. Your operation's output appears between them. Finish any progress bars you create before returning from handle().

The command's normal verbosity options apply, including --quiet and --verbose.

Retrying Operations

Write operations so they can safely run again after a partial failure. For example, update rows that still need changing instead of incrementing every row unconditionally.

An operation can finish its work and then lose its database connection before recording completion. The next run will attempt it again. The ledger prevents repeating recorded successes; it cannot guarantee exactly-once execution of arbitrary side effects.

Forgetting Operations

To deliberately run a completed operation again, forget its completion record using the full filename without .php:

php artisan operations:forget 2026_10_02_120000_backfill_company_names

The command asks for confirmation. Use --force to skip the prompt:

php artisan operations:forget 2026_10_02_120000_backfill_company_names --force

Forgetting only deletes the completion record. It does not undo previous effects, delete the file, or execute the operation. If the file is present, the next operations:run will execute it again. Make sure it is safe to repeat.

You can also forget records whose files have been removed. The command fails if no completion record matches the supplied name. Failed operations do not need to be forgotten; they are already pending.

Transactions

For database work, implement the WithinTransaction marker interface to opt into a transaction:

use DirectoryTree\Operations\Contracts\WithinTransaction;
use DirectoryTree\Operations\Operation;
use Illuminate\Console\Command;

return new class extends Operation implements WithinTransaction
{
    public function handle(Command $command): void
    {
        // Your database changes...
    }
};

The transaction includes the operation and its completion record on the configured operations connection. Writes on other connections, external API calls, and database statements that implicitly commit are outside that guarantee. Transactions are disabled by default so a large backfill does not accidentally hold one transaction open for its entire run.

Dispatching Jobs

Operations always execute in the command's process. They can dispatch ordinary Laravel jobs:

use Illuminate\Console\Command;

public function handle(Command $command): void
{
    RebuildSearchIndex::dispatch();
}

The operation is marked complete after dispatching the job. Laravel's queue handles the job's execution and retries; the operation does not wait for it to finish.

If you enable a transaction, use Laravel's after-commit dispatch behavior for jobs that must see committed changes. Database completion and publishing to an external queue are not one atomic transaction.

Pruning Operations

You can delete old operation files after every database that needs them has completed them. Their completion records remain in the operations table, and operations:status shows the files as missing.

Completed files are never loaded by the runner, so they can remain in the repository even when they reference application code that has since changed.

Pruning is a manual step. A fresh database cannot execute a deleted operation, so keep any setup it still needs in your migrations or seeders.

Deployment

Run operations after your migrations:

set -e

php artisan migrate --force --isolated=1
php artisan operations:run --force --isolated=1

--force skips Laravel's production confirmation. It does not rerun completed operations or enable isolation.

Isolating Operations

Like Laravel migrations, operations:run supports Laravel's isolatable commands. Isolation is opt-in:

php artisan operations:run --force --isolated

Laravel acquires a lock through the application's default cache store before running the command and releases it when the command finishes or throws. If another isolated invocation holds the lock, the command skips execution and exits successfully. Use --isolated=1 when your deployment must fail instead of proceeding without running the operations:

php artisan operations:run --force --isolated=1

Every invocation must use --isolated to participate in locking.

All servers must share the same default cache store and cache prefix for isolation to work across servers.

Laravel's default isolation lock expires after one hour and is not renewed automatically. Operations that run longer may overlap with another invocation.

Migration and operation commands use separate locks; isolation applies only to the command being run.

Configuration

php artisan vendor:publish --tag=operations-config
return [
    'path' => base_path('operations'),

    'connection' => null,
];

connection selects the database connection for the operations table and optional transactions. null uses the application's default connection. It does not change the connection used by your application models.