If you’ve been working with Laravel for a while, you already know about Laravel Custom Artisan Commands — or at least you’ve used the built-in ones without really thinking about it. php artisan make:model, php artisan migrate, php artisan tinker — we all run these a dozen times a day. But here’s the thing most developers miss: you can build your own commands too. Custom ones. Ones that do exactly what your app needs. And once you start doing it, you’ll wonder how you ever lived without it.
In this post, we’re going to go from zero to a fully working custom Artisan command — with real-world examples you can copy into your project today.
What Are Laravel Custom Artisan Commands and Why Should You Build Them?
Artisan is Laravel’s built-in command-line interface. It ships with a ton of helpful commands out of the box, but its real power is that it’s extensible. You can register your own commands and run them the same way you’d run any built-in one — from the terminal, from a scheduler, or even from another part of your code.
👉 Want to dive deeper into everything Artisan can do? Check out the official Laravel Artisan Console documentation here:
https://laravel.com/docs/13.x/artisan
So why would you build a custom command? Here are three situations where it just makes sense:
- Cleaning up old data — say you want to delete users who signed up but never activated their account after 30 days. You could write a one-off script, but a command is cleaner, reusable, and schedulable.
- Sending automated reports — generate a daily summary email to your team every morning at 8am without touching a cron job manually.
- Syncing or seeding data — pulling data from a third-party API and storing it locally? A command makes this trivially easy to trigger on demand or on a schedule.
The moment you ship your first custom Artisan command, you start thinking like a senior Laravel developer. It’s one of those features that separates people who use Laravel from people who understand it.
Step 1 — Create Your First Laravel Custom Artisan Command
Creating a custom command is one terminal line away:
php artisan make:command DeleteInactiveUsersThis creates a new file at app/Console/Commands/DeleteInactiveUsers.php. Open it up and you’ll see something like this:
namespace App\Console\Commands;
use Illuminate\Console\Command;
class DeleteInactiveUsers extends Command
{
/**
* The name and signature of the console command.
* This is how you'll call it: php artisan users:delete-inactive
*/
protected $signature = 'users:delete-inactive';
/**
* A short description shown when you run php artisan list
*/
protected $description = 'Delete users who have been inactive for a specified number of days';
/**
* This is where your command logic lives
*/
public function handle()
{
// Your code goes here
$this->info('Command executed successfully!');
}
}Three things to know about this file:
$signature— this is the command name. Follow thenamespace:actionconvention (e.g.,users:delete-inactive,reports:send-daily).$description— shows up when you runphp artisan list. Keep it short and clear.handle()— this is the method that runs when you call your command. All your logic goes here.
Run it right now with:
php artisan users:delete-inactiveYou’ll see “Command executed successfully!” in your terminal. It’s alive.
Step 2 — Add Arguments and Options to Your Command
A command without any flexibility is just a script. The real power comes from arguments and options.
Arguments are required values you pass in. Options are optional flags — they have defaults and you don’t have to pass them every time.
Here’s how to add both to your $signature:
protected $signature = 'users:delete-inactive
{--days=30 : Number of days of inactivity before deletion}
{--dry-run : Run without actually deleting anything}';Now your command accepts two options:
--days=30— defaults to 30, but you can override it:php artisan users:delete-inactive --days=60--dry-run— a boolean flag. If present, it’strue. If absent, it’sfalse.
Reading these inside handle() is straightforward:
public function handle()
{
// Read the --days option (returns 30 if not passed)
$days = $this->option('days');
// Check if --dry-run flag was passed
$isDryRun = $this->option('dry-run');
if ($isDryRun) {
$this->warn("DRY RUN mode — no data will be deleted.");
}
$this->info("Looking for users inactive for more than {$days} days...");
}Always add a --dry-run option to any command that deletes or modifies data. You’ll thank yourself later when you’re nervous about running it in production for the first time.
Step 3 — Write Real Logic Inside the Command
Let’s make this command actually do something. We want to find users who haven’t logged in for more than N days and delete them.
namespace App\Console\Commands;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Console\Command;
class DeleteInactiveUsers extends Command
{
protected $signature = 'users:delete-inactive
{--days=30 : Days of inactivity threshold}
{--dry-run : Preview without deleting}';
protected $description = 'Delete users who have been inactive for a given number of days';
public function handle()
{
$days = $this->option('days');
$isDryRun = $this->option('dry-run');
// Find users whose last_login_at is older than the threshold
$cutoff = Carbon::now()->subDays($days);
$inactiveUsers = User::where('last_login_at', '<', $cutoff)
->whereNotNull('last_login_at')
->get();
if ($inactiveUsers->isEmpty()) {
$this->info('No inactive users found. All good!');
return;
}
$this->warn("Found {$inactiveUsers->count()} inactive users.");
// If dry run, just show them — don't delete
if ($isDryRun) {
$this->table(
['ID', 'Email', 'Last Login'],
$inactiveUsers->map(fn($u) => [$u->id, $u->email, $u->last_login_at])
);
$this->info('Dry run complete. No users were deleted.');
return;
}
// Ask for confirmation before doing something destructive
if (!$this->confirm("Delete these {$inactiveUsers->count()} users? This cannot be undone.")) {
$this->info('Aborted.');
return;
}
// Delete in chunks to avoid memory issues with large datasets
User::where('last_login_at', '<', $cutoff)
->whereNotNull('last_login_at')
->chunkById(100, function ($chunk) {
foreach ($chunk as $user) {
$user->delete();
$this->line("Deleted user: {$user->email}");
}
});
$this->info('Done. Inactive users have been removed.');
}
}A few things worth pointing out here:
$this->info()prints green text — good for success messages.$this->warn()prints yellow — good for “heads up” messages.$this->error()prints red — good for failure messages.$this->confirm()prompts the user with a yes/no before doing anything destructive.$this->table()prints a nice formatted table right in the terminal.chunkById(100, ...)processes records in batches of 100 — so if you have 50,000 users to delete, you won’t blow your memory limit.
Try running it in dry-run mode first:
php artisan users:delete-inactive --days=60 --dry-runStep 4 — Send a Daily Report via Email (Bonus Real-World Example)
Here’s another pattern you’ll use constantly — a command that queries some data and emails a summary to your team.
First, create the command:
php artisan make:command SendDailyReportThen wire it up:
namespace App\Console\Commands;
use App\Mail\DailyReportMail;
use App\Models\Order;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class SendDailyReport extends Command
{
protected $signature = 'reports:send-daily';
protected $description = 'Send a daily summary report to the admin team';
public function handle()
{
$this->info('Generating daily report...');
// Pull today's stats
$today = Carbon::today();
$stats = [
'new_orders' => Order::whereDate('created_at', $today)->count(),
'revenue' => Order::whereDate('created_at', $today)->sum('total'),
'date' => $today->toFormattedDateString(),
];
// Send the email
Mail::to(config('app.admin_email'))
->send(new DailyReportMail($stats));
$this->info("Report sent! Orders: {$stats['new_orders']}, Revenue: \${$stats['revenue']}");
}
}You’d create a DailyReportMail Mailable separately (just run php artisan make:mail DailyReportMail), but the command itself is clean and simple. The logic is all in one place, easy to test, easy to trigger manually when needed.
Step 5 — Schedule Your Command to Run Automatically
Okay, you have a command. Now let’s make it run on a schedule — no manual triggering required.
For Laravel 10 and below, open app/Console/Kernel.php and add your commands to the schedule method:
protected function schedule(Schedule $schedule)
{
// Delete inactive users every Sunday at midnight
$schedule->command('users:delete-inactive --days=30')->weekly()->sundays()->at('00:00');
// Send daily report every weekday morning at 8am
$schedule->command('reports:send-daily')->weekdays()->at('08:00');
}For Laravel 11+, scheduling moved to routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('users:delete-inactive --days=30')->weekly()->sundays()->at('00:00');
Schedule::command('reports:send-daily')->weekdays()->at('08:00');Then add this single cron entry to your server — this is all you ever need, regardless of how many scheduled commands you have:
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1Laravel’s scheduler takes it from there. It checks every minute and runs whichever commands are due.
A few useful schedule frequencies to know:
->everyMinute() // runs every minute
->hourly() // runs at the top of every hour
->daily() // runs at midnight
->dailyAt('08:30') // runs at a specific time every day
->weekdays() // runs Monday–Friday
->weekly() // runs once a week (Sunday at midnight by default)
->monthly() // runs on the 1st of each month
->cron('0 9 * * 1') // full cron expression for custom schedulesPro Tips and Best Practices
A few things that’ll save you headaches down the road:
- Always add
--dry-runto destructive commands. Before deleting 10,000 records in production, you want to see what would be deleted first. - Log your command output. Add
->appendOutputTo(storage_path('logs/scheduler.log'))to your schedule entry so you have a record of every run. - Use
$this->confirm()for dangerous operations. One accidental Enter shouldn’t wipe your database. - Process large datasets in chunks. Use
chunkById()orlazy()instead of->get()when working with thousands of records. - Write tests for your commands. Laravel’s
artisan()test helper makes this easy:$this->artisan('users:delete-inactive --dry-run')->assertSuccessful(). - Keep
handle()clean. Extract heavy logic into dedicated service classes. Your command should read like a recipe, not a wall of code.
Wrapping Up
That’s really all there is to it. You created a command, added options to make it flexible, wrote real logic inside it, and scheduled it to run automatically. That’s the whole loop.
The best part? This scales to literally anything. Need to sync products from a CSV every night? Command. Want to send a weekly digest email? Command. Need to warm your cache after deployment? Command.
Next time you catch yourself writing a throwaway script or logging into the server to run something manually, stop and ask: should this be an Artisan command? Nine times out of ten, the answer is yes.
Now go build something. php artisan make:command is waiting for you.


Leave a Reply