Artisan, Tinker & Debugging
By the end of this lesson you will be able to write your own Artisan commands for office jobs, try out code instantly in Tinker, and find bugs quickly using logs, dump(), the error page and Laravel's debugging tools. Good debugging habits save more time than any other skill.
Artisan: your command-line assistant
Artisan is the command-line tool in every Laravel project. You have used it all through this course: make:model, migrate, route:list, queue:work. Run php artisan list to see every command, grouped by name:
Laravel Framework 13.39.0 Available commands: about Display basic information about your application db Start a new database CLI session dev Run the dev processes migrate Run the database migrations serve Serve the application on the PHP development server test Run the application tests tinker Interact with your application cache cache:clear Flush the application cache ...
(Shortened; there are well over a hundred.) Add --help to any command to see its arguments and options, for example php artisan migrate --help.
php artisan about gives a one-screen health report. Here is part of it from the coaching app:
Environment ...................................................... Application Name ......................................... Laravel Laravel Version .......................................... 13.39.0 PHP Version ............................................... 8.4.27 Environment ................................................ local Debug Mode ............................................... ENABLED Drivers .......................................................... Cache ................................................... database Database .................................................. sqlite Mail ......................................................... log Queue ................................................... database
Paste this into any bug report or support question. It answers half the questions people would ask you.
Writing your own command
Office tasks that staff run by hand, or that the scheduler runs, belong in commands. Think of a command as a printed form at the office counter: fixed fields (arguments and options) and one clear job.
Step 1. Generate it:
php artisan make:command SendFeeRemindersStep 2. Fill it in. Laravel 13 uses PHP attributes for the name and description:
<?php
namespace App\Console\Commands;
use App\Enums\AdmissionStatus;
use App\Jobs\SendFeeReminder;
use App\Models\Admission;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('fees:remind {--min=1000 : Only remind when the balance is at least this many rupees} {--dry-run : List students without sending}')]
#[Description('Queue fee reminders for admissions with a pending balance')]
class SendFeeReminders extends Command
{
public function handle(): int
{
$min = (int) $this->option('min');
$due = Admission::with(['student', 'payments'])
->where('status', AdmissionStatus::Active)
->get()
->filter(fn (Admission $a) => $a->balance >= $min);
if ($due->isEmpty()) {
$this->info('Nobody owes fees. Nothing to send.');
return self::SUCCESS;
}
$this->table(
['Student', 'Batch ID', 'Balance (Rs)'],
$due->map(fn (Admission $a) => [$a->student->name, $a->batch_id, number_format($a->balance)])->all(),
);
if ($this->option('dry-run')) {
$this->warn('Dry run: no reminders queued.');
return self::SUCCESS;
}
$due->each(fn (Admission $a) => SendFeeReminder::dispatch($a));
$this->info("Queued {$due->count()} reminder(s).");
return self::SUCCESS;
}
}The signature describes the command's input. {--min=1000 : ...} is an option with a default and a help text; {--dry-run} is a true/false flag. Arguments without dashes, like {batch} or {batch?} (optional), are read with $this->argument('batch'). Output helpers include info(), warn(), error(), line(), table() and a progress bar. Interactive helpers such as ask() and confirm() exist too.
Step 3. Run it. Commands in app/Console/Commands are registered automatically.
php artisan fees:remind --min=25000+--------------+----------+--------------+ | Student | Batch ID | Balance (Rs) | +--------------+----------+--------------+ | Priya Sharma | 1 | 30,000 | | Kabir Singh | 2 | 42,000 | +--------------+----------+--------------+ Queued 2 reminder(s).
Its help screen was generated from the signature:
Description:
Queue fee reminders for admissions with a pending balance
Usage:
fees:remind [options]
Options:
--min[=MIN] Only remind when the balance is at least this many rupees [default: "1000"]
--dry-run List students without sendingReturn self::SUCCESS (0) or self::FAILURE (1). Scripts and the scheduler use this exit code to know whether the command worked.
For a tiny one-off command, you can skip the class and write a closure in routes/console.php with Artisan::command('name', function () {...}), like the built-in inspire example.
Tinker: a live console for your app
Tinker is an interactive PHP shell (a REPL: read, evaluate, print, loop) with your whole app loaded. Type a line, press Enter, see the result. It is the quickest way to check a query, a relationship or an accessor.
php artisan tinker> App\Models\Student::count()
= 4
> App\Models\Admission::find(1)->balance
= 30000
> config('coaching.institute_name')
= "Vidya Coaching Rohtak"For one line without opening the shell, use --execute. You saw this in Configuration & .env:
php artisan tinker --execute="echo App\Models\Course::where('code', 'NEET-C')->value('fee');"42000
Be careful in Tinker on a live server: it talks to the real database. A wrong delete() there has real effects.
Logs
Laravel writes errors and your own messages to storage/logs/laravel.log by default. Write messages with the Log facade at a fitting level: debug, info, notice, warning, error, critical, alert, emergency.
use Illuminate\Support\Facades\Log;
Log::info('Payment recorded', ['receipt' => 'RCP-1005', 'amount' => 10000]);
Log::warning('Large payment: RCP-1009 for ₹60,000');[2026-11-02 16:56:28] local.INFO: Payment recorded {"receipt":"RCP-1005","amount":10000,"by_user":null}The second argument, the context array, is stored as JSON. It makes logs searchable. Never log passwords, tokens, OTPs or full Aadhaar numbers.
To watch logs live in a terminal, use Pail, which is included in new projects as a dev dependency:
php artisan pailLOG_LEVEL in .env sets the minimum level written (debug locally; often warning or error in production), and LOG_STACK=daily creates one file per day instead of one growing file.
dump, dd and the error page
dump($value) prints a variable in a readable format and carries on. dd($value) ("dump and die") prints it and stops the script. Use them when you want to see exactly what a variable holds at one point:
public function show(Student $student)
{
dump($student->toArray());
dd($student->admissions()->toRawSql());
}Remove them before committing. A forgotten dd() stops the page for every user.
When an exception happens and APP_DEBUG=true, Laravel shows a detailed error page: the exception message, the exact file and line, the code around it, the request data and the queries that ran. For JSON requests you get the same details as a JSON trace. Read from the top: the first frame inside app/ is usually where your bug is.
More tools worth knowing
| Tool | What it shows | Where |
|---|---|---|
| Telescope | Every request, query, job, mail, log and exception, in a web dashboard | Local development (composer require laravel/telescope --dev) |
| Pulse | Live performance dashboard: slow routes, slow queries, busy users | Production monitoring |
| Nightwatch | Hosted monitoring service from the Laravel team | Production |
| Xdebug | Step-by-step debugging with breakpoints in VS Code or PhpStorm | Local development |
DB::listen() | Every SQL query with its time, for your own logging | Anywhere |
For N+1 problems specifically, Model::preventLazyLoading() from Relationships & Eager Loading catches them the moment they happen.
A simple debugging routine
- Read the error message fully. It usually names the problem and the file.
- Reproduce it. Find the exact steps or the exact request.
- Check the log (
storage/logs/laravel.logorphp artisan pail). - Look at the data with
dump(), Tinker ortoRawSql(). - Clear caches if config or routes look stale:
php artisan optimize:clear. - Write a test that fails because of the bug, fix it, and keep the test. The next lesson shows how.
Common mistakes
- Leaving
dd()ordump()in code. It breaks pages for everyone. Search for them before committing. APP_DEBUG=trueon a live site. The error page reveals code, paths and settings.- Logging secrets. Logs are often shared and kept for months.
- Forgetting
optimize:clearafter changing config on a cached server. - Not returning an exit code from commands. Schedulers and scripts cannot tell success from failure.
Key points
php artisan list,--helpandaboutexplain what Artisan can do and how your app is configured.make:commandcreates a command; the#[Signature]attribute defines arguments and options, andhandle()returnsSUCCESSorFAILURE.- Tinker (
php artisan tinker, or--execute) runs code against your real app and database. - Use
Log::info()and friends with context arrays; read logs instorage/logsor live withphp artisan pail. dump()/dd(), the debug error page, Telescope, Pulse and Xdebug help you find bugs; never leave debug output orAPP_DEBUG=trueon production.
Exercise
Write a command students:import {file} {--city=} that reads a CSV file of name,phone rows from storage/app/private, creates each student with the given city (default "Rohtak"), skips rows whose phone already exists, and prints how many were created and skipped. Return a failure code if the file does not exist.
Show solution
#[Signature('students:import {file : CSV file inside storage/app/private} {--city=Rohtak}')]
#[Description('Import students from a CSV file of name,phone rows')]
class ImportStudents extends Command
{
public function handle(): int
{
$file = $this->argument('file');
if (! Storage::exists($file)) {
$this->error("File not found: {$file}");
return self::FAILURE;
}
$created = 0;
$skipped = 0;
foreach (explode("\n", trim(Storage::get($file))) as $line) {
[$name, $phone] = str_getcsv($line);
$student = Student::firstOrCreate(
['phone' => trim($phone)],
['name' => trim($name), 'city' => $this->option('city')],
);
$student->wasRecentlyCreated ? $created++ : $skipped++;
}
$this->info("Created {$created}, skipped {$skipped}.");
return self::SUCCESS;
}
}firstOrCreate() both checks for an existing phone and creates new rows, and wasRecentlyCreated tells you which happened. Run it with php artisan students:import imports/new.csv --city=Pune.
Quick Quiz
1. How do you read the option --min=5000 inside a command?
Options are read with option(); arguments with argument().
2. What is Tinker?
Tinker is a REPL for trying code against your app.
3. What is the difference between dump() and dd()?
"dd" means dump and die.
4. Where does Laravel write logs by default?
The default stack uses a single file in storage/logs.
Learn Laravel with a mentor
Free lessons get you started. Our PHP & MySQL Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
