Laravel Packagev12.1.0MIT License

Laravel Executor

Laravel Executor turns the steps you run after a deploy into versioned PHP files called executors. Each executor has an up() method that does the work and a down() method that reverses it. php artisan execute runs every executor the server still needs and records the run in a database table, in batches you can roll back, filter by tag, and limit to specific servers.

Run Once or Always

An executor runs a single time per database, or again on every deploy.

Commands and Jobs

Call Artisan commands and dispatch queued jobs from inside an executor.

Batches and Rollback

Every run is recorded in a batch, and the latest batches can be rolled back.

Tags

Group executors with tags and run only the groups you need.

Server Targeting

List the server IP addresses an executor belongs to, and other servers skip it.

Status

See every executor with its type, tags, servers, batch and whether it ran.

Quick Tip

Add php artisan execute to your deploy script right after php artisan migrate, and run it on every server.

#Installation

Install the package with Composer and create its table.

#Requirements

  • PHP 8.2, 8.3, 8.4 or 8.5
  • Laravel 12

#Composer Installation

Terminal
composer require pharaonic/laravel-executor

The service provider is registered automatically.

#Run Migrations

The package loads its migration automatically, so the executors table is created with your other migrations:

Terminal
php artisan migrate

To change the migration before it runs, publish it into database/migrations:

Terminal
php artisan vendor:publish --tag=executor-migrations

#Publish Configuration

Publish the configuration only when you need another connection or table name:

Terminal
php artisan vendor:publish --tag=executor-config

The file is published to config/pharaonic/executor.php.

Installation Complete

You're all set! Create your first executor with php artisan make:executor.

#Upgrading

Pick the part for the release you're on: 12.0.x needs a few renames, while 1.x needs a table migration and changes to every executor file.

#From 12.0.x

12.1.0 keeps the table and the executor classes of 12.0.x; php artisan migrate has nothing to convert. Update the case names and check the behavior changes below.

#Executor Types Are Upper Case

The ExecutorType cases are renamed, and the old names are removed. Executors that still use them stop with an Undefined constant error, so update every executor file:

12.0.x12.1.0
ExecutorType::AlwaysExecutorType::ALWAYS
ExecutorType::OnceExecutorType::ONCE

ExecutorType::ALWAYS->name returns ALWAYS. Compare the case itself ($type === ExecutorType::ONCE), call isOnce(), or use label(), which returns Always or Once.

#Behavior Changes

  • An executor with $servers runs only on those servers, including on its first run. In 12.0.x, the first run happened on every server.
  • php artisan execute takes a cache lock. When several servers deploy at the same time, point EXECUTOR_LOCK_STORE at a store they share, as described in Running Executors.
  • Invalid executor declarations throw InvalidArgumentException, and duplicate executor names throw LogicException, instead of Exception.

#Deprecated Names

DeprecatedUse
php artisan execute:make, php artisan make:executephp artisan make:executor
--tag=config--tag=executor-config
--tag=migrations--tag=executor-migrations

The deprecated names still work.

#From 1.x

On Laravel 12, this line replaces 1.x. Executors become classes with up() and down(), and the executors table gets new columns. Plan the upgrade as a deploy of its own: update the package, migrate the table, then update every executor file.

#1. Update the Package

Composer doesn't move from 1.x to this line on its own, so require it explicitly:

Terminal
composer require pharaonic/laravel-executor:^12.1

#2. Migrate the Table

The package ships a migration that converts the 1.x table and keeps every record:

Terminal
php artisan migrate
1.x columnNow
executorname
tag (text)tags (JSON array: cache becomes ["cache"])
servers (new, empty)

type, batch, executed and last_executed_at keep their values, so executors that already ran don't run again. The migration only changes tables that still have the 1.x executor column.

Back Up First

The migration drops the executor and tag columns after copying them, and it can't be rolled back to the 1.x shape. Back up the executors table before you migrate.

#3. Update Every Executor File

1.xNow
use Pharaonic\Laravel\Executor\Executor;use Pharaonic\Laravel\Executor\Classes\Executor;
public function handle(): voidpublic function up(): void, plus a down() method
public $tag = 'cache';public $tags = ['cache'];
ExecutorType::Always / ExecutorType::OnceExecutorType::ALWAYS / ExecutorType::ONCE
executors/2024_07_22_100000_clear_cache.php
1use Pharaonic\Laravel\Executor\Enums\ExecutorType;
2use Pharaonic\Laravel\Executor\Executor;
3 
4return new class extends Executor
5{
6 public $type = ExecutorType::Once;
7 
8 public $tag = 'cache';
9 
10 public function handle(): void
11 {
12 $this->command('cache:clear');
13 }
14};
executors/2024_07_22_100000_clear_cache.php
1use Pharaonic\Laravel\Executor\Classes\Executor;
2use Pharaonic\Laravel\Executor\Enums\ExecutorType;
3 
4return new class () extends Executor {
5 public $type = ExecutorType::ONCE;
6 
7 public $tags = ['cache'];
8 
9 public function up(): void
10 {
11 $this->command('cache:clear');
12 }
13 
14 public function down(): void
15 {
16 //
17 }
18};

Keep each file name: it is the executor's name, and the migrated records point at it.

Old Executors Stop the Command

php artisan execute reads every executor file. A file that still extends the 1.x class, uses handle(), or uses ExecutorType::Once stops the whole command with an error, so update all of them before you deploy.

#4. Update Code That Uses the Package

1.xNow
Pharaonic\Laravel\Executor\Facades\ExecutorPool::addPath($path)Pharaonic\Laravel\Executor\Facades\Executor::getPool()->addPath($path)
ExecutorPool::getPaths()Executor::getPool()->getPaths()
php artisan execute:makephp artisan make:executor (the old name still works and prints a deprecation notice)
--tag=migrations--tag=executor-migrations

ExecutorType::ALWAYS->name returns ALWAYS. Compare the case itself ($type === ExecutorType::ONCE), call isOnce(), or use label(), which returns Always or Once.

#What's New

#Configuration

The package works without any configuration. Publish the file when you need another connection or table:

Terminal
php artisan vendor:publish --tag=executor-config
config/pharaonic/executor.php
1return [
2 /**
3 * The database connection that should be used by the executor.
4 */
5 'connection' => env('EXECUTOR_CONNECTION', config('database.default')),
6 
7 /**
8 * The table that should be used to store the executors.
9 */
10 'table' => env('EXECUTOR_TABLE', 'executors'),
11 
12 /**
13 * The lock that makes servers deploying at the same time run the
14 * executors one after another. Use a cache store every server shares
15 * (redis, database, memcached or dynamodb); null uses the default store.
16 * `seconds` is how long a run may hold the lock and how long the next
17 * server waits for it.
18 */
19 'lock' => [
20 'store' => env('EXECUTOR_LOCK_STORE'),
21 'seconds' => env('EXECUTOR_LOCK_SECONDS', 600),
22 ],
23];
KeyEnvironment variableDefaultDescription
pharaonic.executor.connectionEXECUTOR_CONNECTIONyour default connectionDatabase connection of the executors table
pharaonic.executor.tableEXECUTOR_TABLEexecutorsName of the executors table
pharaonic.executor.lock.storeEXECUTOR_LOCK_STOREyour default cache storeCache store of the lock around php artisan execute
pharaonic.executor.lock.secondsEXECUTOR_LOCK_SECONDS600How long a run may hold the lock, and how long another server waits for it
Same Database on Every Server

All your servers should share the same executors table. The table is how a ONCE executor knows it already ran.

#Basic Usage

Create an executor, describe the work in up(), and run it.

#1. Create an Executor

Terminal
php artisan make:executor WarmCaches

The command creates a timestamped file in the executors folder of your project, such as executors/2026_01_15_120000_warm_caches.php. The file name without .php is the executor's name.

#2. Write the Task

executors/2026_01_15_120000_warm_caches.php
1use Pharaonic\Laravel\Executor\Classes\Executor;
2use Pharaonic\Laravel\Executor\Enums\ExecutorType;
3 
4return new class () extends Executor {
5 public $type = ExecutorType::ONCE;
6 
7 public $tags = [];
8 
9 public $servers = [];
10 
11 public function up(): void
12 {
13 $this->command('optimize');
14 }
15 
16 public function down(): void
17 {
18 $this->command('optimize:clear');
19 }
20};

#3. Run It

Terminal
php artisan execute
Executing 2026_01_15_120000_warm_caches...

Running the command again does nothing, because a ONCE executor has already run:

There are no executors need to be executed.

#4. Check the Status

Terminal
php artisan execute:status
+-----------------------------------+------+------+---------+-------+----------+
| Name | Type | Tags | Servers | Batch | Executed |
+-----------------------------------+------+------+---------+-------+----------+
| 2026_01_15_120000_warm_caches | Once | None | All | 1 | Yes |
+-----------------------------------+------+------+---------+-------+----------+

#Writing Executors

An executor is a PHP file that returns an instance of an anonymous class extending Pharaonic\Laravel\Executor\Classes\Executor. Files that don't return an executor are skipped.

#Type

$type decides how often the executor runs. It defaults to ExecutorType::ALWAYS.

TypeRuns
ExecutorType::ONCEThe first time php artisan execute sees it, then never again
ExecutorType::ALWAYSEvery time php artisan execute runs
executors/2026_01_15_120000_warm_caches.php
1public $type = ExecutorType::ONCE;

#Tags

$tags groups executors, so you can run only some of them with --tags.

executors/2026_01_15_120000_warm_caches.php
1public $tags = ['cache', 'search'];

#Servers

$servers lists the IP addresses of the servers the executor belongs to. Leave it empty to run on every server.

executors/2026_01_15_120000_warm_caches.php
1public $servers = ['10.0.0.11', '10.0.0.12'];

The package compares the list with the addresses of the server's network interfaces, ignoring 127.0.0.1 and ::1. On the other servers, the executor is skipped and doesn't appear as run.

Once Means Once for All Servers

Every server shares the executors table, so a ONCE executor runs on only one of its servers: the first one that deploys. Use ALWAYS for work every listed server must do itself, such as clearing local caches, and ONCE for work that one server does for all of them, such as dispatching a job.

#Running Artisan Commands

$this->command() calls an Artisan command, prints its output, and returns its exit code.

1public function up(): void
2{
3 $this->command('cache:clear');
4 $this->command('queue:restart');
5}

#Dispatching Jobs

$this->job() dispatches a job. Pass the class name and its constructor arguments, or a job instance. It returns Laravel's pending dispatch, so you can chain onQueue(), delay() and the other dispatch options.

1use App\Jobs\RecalculateRatings;
2 
3public function up(): void
4{
5 $this->job(RecalculateRatings::class, 'electronics')->onQueue('ratings');
6 $this->job(new RecalculateRatings('books'))->delay(now()->addMinutes(5));
7}
Closures

job() doesn't accept closures or other callables and throws InvalidArgumentException. Use a job class.

#Writing Output

Executors use Laravel's console output helpers, such as info(), line(), warn(), error() and table().

1public function up(): void
2{
3 $count = Product::query()->whereNull('slug')->count();
4 
5 $this->info("Generating {$count} slugs...");
6}

#Reversing an Executor

down() runs when the executor's batch is rolled back. Leave it empty when there's nothing to undo.

#Validation

The executor checks its properties when it is created and throws InvalidArgumentException when:

  • $type isn't an ExecutorType case;
  • $tags isn't an array;
  • $servers isn't an array.

#Running Executors

php artisan execute reads every executor file, picks the ones that should run, and runs them in file-name order.

Terminal
php artisan execute

An executor runs when it has never run before, or when its type is ALWAYS. Executors limited to other servers are skipped.

#Batches

Every executor that runs for the first time in the same php artisan execute call gets the same batch number, one higher than the highest batch in the table. An ALWAYS executor keeps the batch of its first run; the package counts its runs and stores the time of the last one.

#Several Servers at Once

When servers deploy at the same time, php artisan execute takes a cache lock, so they run the executors one after another. The next server reads the executors table only after the lock is free, so a ONCE executor that another server just ran is skipped.

If the lock isn't free within pharaonic.executor.lock.seconds (600 by default), the command fails with exit code 1:

Another server has been running the executors for more than 600 seconds.

If the cache store itself fails, for example a database store without its tables, the command stops with exit code 1 before running any executor, and prints the store's error.

Shared Cache Store

The lock only works across servers when they share its cache store, such as redis, database, memcached or dynamodb. With the file or array store, each server has its own lock. Set the store with EXECUTOR_LOCK_STORE.

#Running One Executor

Pass the executor name, which is its file name without .php:

Terminal
php artisan execute 2026_01_15_120000_warm_caches

#Filtering by Tags

--tags takes a comma-separated list and runs the executors that have at least one of the tags:

Terminal
php artisan execute --tags=cache,search

#Starting Over

execute:fresh empties the executors table and runs php artisan execute, so every executor runs again:

Terminal
php artisan execute:fresh
Production

execute:fresh runs every ONCE executor again. Use it in local and testing environments.

#Rolling Back

execute:rollback reverses the latest batch: it calls down() on each executor of the batch and deletes their records, so they run again on the next php artisan execute.

Terminal
php artisan execute:rollback

Roll back several batches with --steps:

Terminal
php artisan execute:rollback --steps=3
Run It on the Right Server

A rollback runs down() on the server where you run the command, even for executors whose $servers list other servers, and then deletes the records for every server. Run execute:rollback on a server the rolled-back executors belong to.

Deleted Files

The records of the rolled-back batches are deleted even when an executor file no longer exists; down() only runs for the files that do.

#Status

execute:status lists every executor file with its record:

Terminal
php artisan execute:status
+-----------------------------------+--------+---------------+-----------+-------+----------+
| Name | Type | Tags | Servers | Batch | Executed |
+-----------------------------------+--------+---------------+-----------+-------+----------+
| 2026_01_15_120000_warm_caches | Once | cache, search | 10.0.0.11 | 1 | Yes |
| 2026_01_16_090000_sync_currencies | Always | None | All | N/A | No |
+-----------------------------------+--------+---------------+-----------+-------+----------+
ColumnShows
NameThe file name without .php
TypeOnce or Always
TagsThe executor's tags, or None
ServersThe server IP addresses, or All
BatchThe batch of the first run, or N/A when it never ran
ExecutedWhether it has run at least once

The same data is available in code through the facade:

1use Pharaonic\Laravel\Executor\Facades\Executor;
2 
3Executor::info();
4// ['2026_01_15_120000_warm_caches' => ['name' => ..., 'path' => ..., 'type' => ExecutorType::ONCE, 'tags' => ['cache', 'search'], 'servers' => ['10.0.0.11'], 'batch' => 1, 'executed' => true], ...]

#Executor Paths

Executors live in the executors folder of your project. Create one somewhere else with --path, relative to the project root:

Terminal
php artisan make:executor ImportCountries --path=modules/geo/executors

Then register the folder in a service provider, so execute, execute:rollback and execute:status read it too:

app/Providers/AppServiceProvider.php
1use Pharaonic\Laravel\Executor\Facades\Executor;
2 
3public function boot(): void
4{
5 Executor::getPool()->addPath(base_path('modules/geo/executors'));
6}

The package reads the .php files directly inside each folder, not in subfolders.

Unique Names

Executor names must be unique across all folders. Two files with the same name throw LogicException.

#API Reference

#Artisan Commands

CommandDescription
make:executor {name} {--path=}Create an executor in executors, or in --path relative to the project root
execute {name?} {--tags=}Run the executors that should run, optionally one by name or those with one of the tags
execute:rollback {--steps=1}Call down() on the executors of the latest batches and delete their records
execute:freshEmpty the executors table and run execute
execute:statusList every executor with its type, tags, servers, batch and status

#Executor

Pharaonic\Laravel\Executor\Classes\Executor, the class every executor extends.

MemberDescription
public $typeExecutorType::ONCE or ExecutorType::ALWAYS (default)
public $tagsArray of tags, empty by default
public $serversArray of server IP addresses, empty for every server
up(): voidRuns the task
down(): voidReverses the task on rollback
command(string $command, array $parameters = []): intCalls an Artisan command and returns its exit code
job(string|object $job, mixed ...$arguments): PendingDispatchDispatches a job class with its constructor arguments, or a job instance

#ExecutorType

Pharaonic\Laravel\Executor\Enums\ExecutorType, a backed enum that uses Pharaonic\SmartEnum\SmartEnum.

MemberDescriptionReturns
ExecutorType::ALWAYSRuns on every execute, stored as 1ExecutorType
ExecutorType::ONCERuns a single time, stored as 2ExecutorType
isAlways()Whether the type is ALWAYSbool
isOnce()Whether the type is ONCEbool
label()Always or Oncestring
options()Labels keyed by value: [1 => 'Always', 2 => 'Once']array
names() / values() / labels()Every case name, value or labelarray
fromName() / tryFromName() / hasName()Find a case by name ('ONCE')ExecutorType / ?ExecutorType / bool
hasValue()Whether a case has the valuebool
is() / isNot() / in() / notIn() / eq()Compare with a case, name or valuebool
from() / tryFrom() / cases()PHP's native enum methods

#Facade

Pharaonic\Laravel\Executor\Facades\Executor

MethodDescriptionReturns
getPool()The pool of executor foldersExecutorPool
getRecords()Every executor record, keyed by nameCollection
getNextBatchNumber()One higher than the highest batchint
getIPs()The server's IP addresses, without 127.0.0.1 and ::1array
info()Every executor's name, path, type, tags, servers, batch and status, keyed by namearray

#ExecutorPool

Pharaonic\Laravel\Executor\Classes\ExecutorPool, returned by Executor::getPool().

MethodDescriptionReturns
addPath(string $path)Adds a folder of executorsstatic
getPaths()The executor foldersarray

#Model

Pharaonic\Laravel\Executor\Models\Executor, one record per executor that has run.

AttributeTypeDescription
idintPrimary key
typeExecutorTypeThe executor's type
namestringThe executor's file name without .php
tags?arrayThe executor's tags
servers?arrayThe executor's server IP addresses
batch?intThe batch of the first run
executedintHow many times it has run
last_executed_at?CarbonInterfaceWhen it last ran
MethodDescriptionReturns
isNew()Whether it has never runbool
isExecutable()Whether its type is ALWAYS or it has never runbool
execute()Counts a run and stores its timevoid

#Publish Tags

TagPublishes
executor-config, pharaonic-configconfig/pharaonic/executor.php
executor-migrations, pharaonic-migrationsThe migration, into database/migrations
laravel-executor, pharaonicBoth

#Examples

#1. Deploy Script

Run the executors on every server after the migrations.

deploy.sh
1php artisan down
2git pull origin main
3composer install --no-dev --optimize-autoloader
4php artisan migrate --force
5php artisan execute
6php artisan up

#2. One-Time Data Fix

Fill a new column once, in chunks, and clear it again on rollback.

executors/2026_02_01_090000_fill_product_slugs.php
1use App\Models\Product;
2use Illuminate\Support\Str;
3use Pharaonic\Laravel\Executor\Classes\Executor;
4use Pharaonic\Laravel\Executor\Enums\ExecutorType;
5 
6return new class () extends Executor {
7 public $type = ExecutorType::ONCE;
8 
9 public function up(): void
10 {
11 Product::query()->whereNull('slug')->chunkById(500, function ($products) {
12 foreach ($products as $product) {
13 $product->update(['slug' => Str::slug($product->name)]);
14 }
15 });
16 }
17 
18 public function down(): void
19 {
20 Product::query()->update(['slug' => null]);
21 }
22};

#3. Caches on Every Deploy

Rebuild the caches on every deploy, on every server.

executors/2026_02_01_090100_rebuild_caches.php
1use Pharaonic\Laravel\Executor\Classes\Executor;
2use Pharaonic\Laravel\Executor\Enums\ExecutorType;
3 
4return new class () extends Executor {
5 public $type = ExecutorType::ALWAYS;
6 
7 public $tags = ['cache'];
8 
9 public function up(): void
10 {
11 $this->command('optimize');
12 $this->command('view:cache');
13 }
14 
15 public function down(): void
16 {
17 $this->command('optimize:clear');
18 }
19};

Rebuild only the caches, without the other executors:

Terminal
php artisan execute --tags=cache

#4. A Task for the Worker Servers

Queue a recalculation once, from one of the servers that run the queue workers.

executors/2026_02_01_090200_recalculate_ratings.php
1use App\Jobs\RecalculateRatings;
2use Pharaonic\Laravel\Executor\Classes\Executor;
3use Pharaonic\Laravel\Executor\Enums\ExecutorType;
4 
5return new class () extends Executor {
6 public $type = ExecutorType::ONCE;
7 
8 public $tags = ['ratings'];
9 
10 public $servers = ['10.0.0.21', '10.0.0.22'];
11 
12 public function up(): void
13 {
14 $this->info('Queueing the rating recalculation...');
15 
16 $this->job(RecalculateRatings::class)->onQueue('ratings');
17 }
18 
19 public function down(): void
20 {
21 //
22 }
23};
app/Jobs/RecalculateRatings.php
1namespace App\Jobs;
2 
3use App\Models\Product;
4use Illuminate\Bus\Queueable;
5use Illuminate\Contracts\Queue\ShouldQueue;
6use Illuminate\Foundation\Bus\Dispatchable;
7use Illuminate\Queue\InteractsWithQueue;
8 
9class RecalculateRatings implements ShouldQueue
10{
11 use Dispatchable, InteractsWithQueue, Queueable;
12 
13 public function handle(): void
14 {
15 Product::query()->chunkById(500, function ($products) {
16 foreach ($products as $product) {
17 $product->update(['rating' => $product->reviews()->avg('stars') ?? 0]);
18 }
19 });
20 }
21}

#Troubleshooting

#"There are no executors need to be executed."

Every ONCE executor has already run and there's no ALWAYS executor. Check the list with php artisan execute:status. If the executor lives outside the executors folder, register its folder with Executor::getPool()->addPath().

#"Undefined constant ExecutorType::Once"

The executor uses a case name from 1.x or 12.0.x. Rename ExecutorType::Always to ExecutorType::ALWAYS and ExecutorType::Once to ExecutorType::ONCE, as described in Upgrading.

#"Class Pharaonic\Laravel\Executor\Executor not found"

The executor still extends the 1.x class. Use Pharaonic\Laravel\Executor\Classes\Executor, rename handle() to up(), and add a down() method, as described in Upgrading.

#"Column not found: name" after upgrading from 1.x

The executors table still has the 1.x shape. Run php artisan migrate, which converts it.

#"Another server has been running the executors for more than 600 seconds."

Another php artisan execute holds the lock: a deploy is still running, or a run stopped before releasing it and the lock hasn't expired yet. Wait for the other deploy, or raise EXECUTOR_LOCK_SECONDS when your executors take longer than 600 seconds.

#"Couldn't take the executor lock on the [database] cache store"

php artisan execute couldn't reach the cache store of its lock, so it stopped before running anything. The message ends with the store's own error. With the database store, create the cache tables (php artisan make:cache-table, then php artisan migrate), or point EXECUTOR_LOCK_STORE at another store every server shares, such as redis.

#A ONCE executor ran on two servers

The servers don't share the lock's cache store. Point EXECUTOR_LOCK_STORE at a store every server uses, such as redis or database.

#An executor doesn't appear in execute:status

The file must be directly inside an executor folder, end in .php, and return the executor instance (return new class () extends Executor { ... };). Files that return anything else are skipped.

#"Duplicate Executor name"

Two folders contain a file with the same name. Rename one of them, keeping its timestamp prefix.

#"The type of the executor must be an instance of ExecutorType."

$type holds something other than a case, such as 1 or 'once'. Use ExecutorType::ONCE or ExecutorType::ALWAYS.

#An executor runs on the wrong server

$servers is compared with the addresses of the server's network interfaces. Behind NAT or a load balancer, use the server's private address. Check what the package sees with php artisan tinker and Executor::getIPs().

#execute:make prints a deprecation notice

The command was renamed. Use php artisan make:executor.

Contributors

Amazing people who made this package possible

This package wouldn't exist without the contributions of our amazing community. A huge thank you to everyone who has helped improve Executor!

Moamen Eltouny

@MoamenEltouny

122 contributions

Karim Mohamed

@komtcho

11 contributions

Moemen Gaballah

@Moemen-Gaballah

3 contributions

Elsayed Elbeshry

@Elsayed93

1 contribution

Want to Contribute?

We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.