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.
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
composer require pharaonic/laravel-executorThe service provider is registered automatically.
#Run Migrations
The package loads its migration automatically, so the executors table is created with your other migrations:
php artisan migrateTo change the migration before it runs, publish it into database/migrations:
php artisan vendor:publish --tag=executor-migrations#Publish Configuration
Publish the configuration only when you need another connection or table name:
php artisan vendor:publish --tag=executor-configThe file is published to config/pharaonic/executor.php.
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.x | 12.1.0 |
|---|---|
ExecutorType::Always | ExecutorType::ALWAYS |
ExecutorType::Once | ExecutorType::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
$serversruns only on those servers, including on its first run. In 12.0.x, the first run happened on every server. php artisan executetakes a cache lock. When several servers deploy at the same time, pointEXECUTOR_LOCK_STOREat a store they share, as described in Running Executors.- Invalid executor declarations throw
InvalidArgumentException, and duplicate executor names throwLogicException, instead ofException.
#Deprecated Names
| Deprecated | Use |
|---|---|
php artisan execute:make, php artisan make:execute | php 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:
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:
php artisan migrate| 1.x column | Now |
|---|---|
executor | name |
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.
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.x | Now |
|---|---|
use Pharaonic\Laravel\Executor\Executor; | use Pharaonic\Laravel\Executor\Classes\Executor; |
public function handle(): void | public function up(): void, plus a down() method |
public $tag = 'cache'; | public $tags = ['cache']; |
ExecutorType::Always / ExecutorType::Once | ExecutorType::ALWAYS / ExecutorType::ONCE |
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(): void11 {12 $this->command('cache:clear');13 }14}; 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(): void10 {11 $this->command('cache:clear');12 }13 14 public function down(): void15 {16 //17 }18};Keep each file name: it is the executor's name, and the migrated records point at it.
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.x | Now |
|---|---|
Pharaonic\Laravel\Executor\Facades\ExecutorPool::addPath($path) | Pharaonic\Laravel\Executor\Facades\Executor::getPool()->addPath($path) |
ExecutorPool::getPaths() | Executor::getPool()->getPaths() |
php artisan execute:make | php 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
- Rolling back batches with
down(). - Server targeting with
$servers, and several tags per executor. - A lock so servers deploying at the same time don't run a
ONCEexecutor twice. - A configuration file for the connection, table and lock.
#Configuration
The package works without any configuration. Publish the file when you need another connection or table:
php artisan vendor:publish --tag=executor-config 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 the14 * executors one after another. Use a cache store every server shares15 * (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 next17 * server waits for it.18 */19 'lock' => [20 'store' => env('EXECUTOR_LOCK_STORE'),21 'seconds' => env('EXECUTOR_LOCK_SECONDS', 600),22 ],23];| Key | Environment variable | Default | Description |
|---|---|---|---|
pharaonic.executor.connection | EXECUTOR_CONNECTION | your default connection | Database connection of the executors table |
pharaonic.executor.table | EXECUTOR_TABLE | executors | Name of the executors table |
pharaonic.executor.lock.store | EXECUTOR_LOCK_STORE | your default cache store | Cache store of the lock around php artisan execute |
pharaonic.executor.lock.seconds | EXECUTOR_LOCK_SECONDS | 600 | How long a run may hold the lock, and how long another server waits for it |
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
php artisan make:executor WarmCachesThe 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
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(): void12 {13 $this->command('optimize');14 }15 16 public function down(): void17 {18 $this->command('optimize:clear');19 }20};#3. Run It
php artisan executeExecuting 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
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.
| Type | Runs |
|---|---|
ExecutorType::ONCE | The first time php artisan execute sees it, then never again |
ExecutorType::ALWAYS | Every time php artisan execute runs |
1public $type = ExecutorType::ONCE;#Tags
$tags groups executors, so you can run only some of them with --tags.
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.
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.
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(): void2{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(): void4{5 $this->job(RecalculateRatings::class, 'electronics')->onQueue('ratings');6 $this->job(new RecalculateRatings('books'))->delay(now()->addMinutes(5));7}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(): void2{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:
$typeisn't anExecutorTypecase;$tagsisn't an array;$serversisn'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.
php artisan executeAn 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.
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:
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:
php artisan execute --tags=cache,search#Starting Over
execute:fresh empties the executors table and runs php artisan execute, so every executor runs again:
php artisan execute:freshexecute: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.
php artisan execute:rollbackRoll back several batches with --steps:
php artisan execute:rollback --steps=3A 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.
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:
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 |+-----------------------------------+--------+---------------+-----------+-------+----------+| Column | Shows |
|---|---|
| Name | The file name without .php |
| Type | Once or Always |
| Tags | The executor's tags, or None |
| Servers | The server IP addresses, or All |
| Batch | The batch of the first run, or N/A when it never ran |
| Executed | Whether 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:
php artisan make:executor ImportCountries --path=modules/geo/executorsThen register the folder in a service provider, so execute, execute:rollback and execute:status read it too:
1use Pharaonic\Laravel\Executor\Facades\Executor;2 3public function boot(): void4{5 Executor::getPool()->addPath(base_path('modules/geo/executors'));6}The package reads the .php files directly inside each folder, not in subfolders.
Executor names must be unique across all folders. Two files with the same name throw LogicException.
#API Reference
#Artisan Commands
| Command | Description |
|---|---|
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:fresh | Empty the executors table and run execute |
execute:status | List every executor with its type, tags, servers, batch and status |
#Executor
Pharaonic\Laravel\Executor\Classes\Executor, the class every executor extends.
| Member | Description |
|---|---|
public $type | ExecutorType::ONCE or ExecutorType::ALWAYS (default) |
public $tags | Array of tags, empty by default |
public $servers | Array of server IP addresses, empty for every server |
up(): void | Runs the task |
down(): void | Reverses the task on rollback |
command(string $command, array $parameters = []): int | Calls an Artisan command and returns its exit code |
job(string|object $job, mixed ...$arguments): PendingDispatch | Dispatches 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.
| Member | Description | Returns |
|---|---|---|
ExecutorType::ALWAYS | Runs on every execute, stored as 1 | ExecutorType |
ExecutorType::ONCE | Runs a single time, stored as 2 | ExecutorType |
isAlways() | Whether the type is ALWAYS | bool |
isOnce() | Whether the type is ONCE | bool |
label() | Always or Once | string |
options() | Labels keyed by value: [1 => 'Always', 2 => 'Once'] | array |
names() / values() / labels() | Every case name, value or label | array |
fromName() / tryFromName() / hasName() | Find a case by name ('ONCE') | ExecutorType / ?ExecutorType / bool |
hasValue() | Whether a case has the value | bool |
is() / isNot() / in() / notIn() / eq() | Compare with a case, name or value | bool |
from() / tryFrom() / cases() | PHP's native enum methods |
#Facade
Pharaonic\Laravel\Executor\Facades\Executor
| Method | Description | Returns |
|---|---|---|
getPool() | The pool of executor folders | ExecutorPool |
getRecords() | Every executor record, keyed by name | Collection |
getNextBatchNumber() | One higher than the highest batch | int |
getIPs() | The server's IP addresses, without 127.0.0.1 and ::1 | array |
info() | Every executor's name, path, type, tags, servers, batch and status, keyed by name | array |
#ExecutorPool
Pharaonic\Laravel\Executor\Classes\ExecutorPool, returned by Executor::getPool().
| Method | Description | Returns |
|---|---|---|
addPath(string $path) | Adds a folder of executors | static |
getPaths() | The executor folders | array |
#Model
Pharaonic\Laravel\Executor\Models\Executor, one record per executor that has run.
| Attribute | Type | Description |
|---|---|---|
id | int | Primary key |
type | ExecutorType | The executor's type |
name | string | The executor's file name without .php |
tags | ?array | The executor's tags |
servers | ?array | The executor's server IP addresses |
batch | ?int | The batch of the first run |
executed | int | How many times it has run |
last_executed_at | ?CarbonInterface | When it last ran |
| Method | Description | Returns |
|---|---|---|
isNew() | Whether it has never run | bool |
isExecutable() | Whether its type is ALWAYS or it has never run | bool |
execute() | Counts a run and stores its time | void |
#Publish Tags
| Tag | Publishes |
|---|---|
executor-config, pharaonic-config | config/pharaonic/executor.php |
executor-migrations, pharaonic-migrations | The migration, into database/migrations |
laravel-executor, pharaonic | Both |
#Examples
#1. Deploy Script
Run the executors on every server after the migrations.
1php artisan down2git pull origin main3composer install --no-dev --optimize-autoloader4php artisan migrate --force5php artisan execute6php artisan up#2. One-Time Data Fix
Fill a new column once, in chunks, and clear it again on rollback.
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(): void10 {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(): void19 {20 Product::query()->update(['slug' => null]);21 }22};#3. Caches on Every Deploy
Rebuild the caches on every deploy, on every server.
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(): void10 {11 $this->command('optimize');12 $this->command('view:cache');13 }14 15 public function down(): void16 {17 $this->command('optimize:clear');18 }19};Rebuild only the caches, without the other executors:
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.
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(): void13 {14 $this->info('Queueing the rating recalculation...');15 16 $this->job(RecalculateRatings::class)->onQueue('ratings');17 }18 19 public function down(): void20 {21 //22 }23}; 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 ShouldQueue10{11 use Dispatchable, InteractsWithQueue, Queueable;12 13 public function handle(): void14 {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!
Want to Contribute?
We welcome contributions from everyone! Whether it's bug fixes, new features, or documentation improvements - every contribution counts.