EthicsComputerCourses
Join training +91 90532 10052

Artisan, Tinker & Debugging

Laravel · Lesson 24 of 30 Advanced 8 min read Updated

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:

Output
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:

Output
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:

Terminal
php artisan make:command SendFeeReminders

Step 2. Fill it in. Laravel 13 uses PHP attributes for the name and description:

PHP
<?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.

Terminal
php artisan fees:remind --min=25000
Output
+--------------+----------+--------------+
| 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:

Output
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 sending

Return 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.

Terminal
php artisan tinker
PHP
> 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:

Terminal
php artisan tinker --execute="echo App\Models\Course::where('code', 'NEET-C')->value('fee');"
Output
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.

PHP
use Illuminate\Support\Facades\Log;

Log::info('Payment recorded', ['receipt' => 'RCP-1005', 'amount' => 10000]);
Log::warning('Large payment: RCP-1009 for ₹60,000');
Output
[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:

Terminal
php artisan pail

LOG_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:

PHP
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

ToolWhat it showsWhere
TelescopeEvery request, query, job, mail, log and exception, in a web dashboardLocal development (composer require laravel/telescope --dev)
PulseLive performance dashboard: slow routes, slow queries, busy usersProduction monitoring
NightwatchHosted monitoring service from the Laravel teamProduction
XdebugStep-by-step debugging with breakpoints in VS Code or PhpStormLocal development
DB::listen()Every SQL query with its time, for your own loggingAnywhere

For N+1 problems specifically, Model::preventLazyLoading() from Relationships & Eager Loading catches them the moment they happen.

A simple debugging routine

  1. Read the error message fully. It usually names the problem and the file.
  2. Reproduce it. Find the exact steps or the exact request.
  3. Check the log (storage/logs/laravel.log or php artisan pail).
  4. Look at the data with dump(), Tinker or toRawSql().
  5. Clear caches if config or routes look stale: php artisan optimize:clear.
  6. Write a test that fails because of the bug, fix it, and keep the test. The next lesson shows how.

Common mistakes

  • Leaving dd() or dump() in code. It breaks pages for everyone. Search for them before committing.
  • APP_DEBUG=true on a live site. The error page reveals code, paths and settings.
  • Logging secrets. Logs are often shared and kept for months.
  • Forgetting optimize:clear after 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, --help and about explain what Artisan can do and how your app is configured.
  • make:command creates a command; the #[Signature] attribute defines arguments and options, and handle() returns SUCCESS or FAILURE.
  • Tinker (php artisan tinker, or --execute) runs code against your real app and database.
  • Use Log::info() and friends with context arrays; read logs in storage/logs or live with php artisan pail.
  • dump()/dd(), the debug error page, Telescope, Pulse and Xdebug help you find bugs; never leave debug output or APP_DEBUG=true on 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
PHP
#[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?

2. What is Tinker?

3. What is the difference between dump() and dd()?

4. Where does Laravel write logs by default?

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