Muhammad Yasir Amin

Loading website…

More from Muhammad Yasir Amin

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Subscribe on YouTube

Lesson 4: Laravel Scheduler & Cron Jobs with Docker

✍️ Build & Learn14 min read

Lesson 4: Laravel Scheduler & Cron Jobs with Docker

Muhammad Yasir Amin

Muhammad Yasir Amin

Senior Full Stack Engineer•October 16, 2026
Lesson 4: Laravel Scheduler & Cron Jobs with Docker

Laravel Docker Lessons · Lesson 4

Run Laravel scheduled tasks in Docker and watch a real five-second example on a Blade page. This lesson creates one synthetic user per run, with a random test name, a timestamped unique email and a hashed password. It also carries forward the Nginx fixes needed after the Lesson 3 snapshot.

The source of truth is the lesson-4 tag. Repository access may require authorization. The recorded Urdu walkthrough is now available: watch Lesson 4 on YouTube.

Builds on the lesson-3 snapshot. This lesson fixes that snapshot's Nginx configuration, adds a Docker scheduler service, and displays generated users on a Blade page.

How it works

  • app/Console/Commands/CreateTestUser.php registers demo:create-user. One invocation creates one user, not a permanent loop.
  • routes/console.php schedules the command with everyFiveSeconds(). The schedule is skipped unless scheduler-demo.enabled is true. withoutOverlapping(1) uses a one-minute lock expiry to avoid overlapping command executions in the single scheduler container.
  • The optional Compose scheduler service runs php artisan schedule:work --no-interaction in the foreground using the same PHP 8.2 FPM image and source as web. It explicitly enables the example. The web service leaves the example disabled.
  • Both services connect to MySQL at db:3306. The scheduler uses the file cache for its local overlap lock; run just one scheduler replica. This setup does not claim a distributed lock across containers.
  • /scheduler-demo renders a paginated Blade table of users marked is_scheduler_demo = true. It shows ID, name, email and UTC creation time, with newest users first. Automatic five-second page refresh can be paused. Passwords and ordinary users are not displayed.

The name is test plus a random integer from 1 to 1000, such as test42. The email includes that name, a UTC timestamp, a ULID suffix and the reserved domain example.com. For example, the format is test42.YYYYMMDDHHMMSS.ulid@example.com. The suffix avoids collisions if two invocations have the same name and second. Passwords are random and hashed through the existing User model. No email is sent.

Why a scheduler worker instead of a five-second crontab

Ordinary five-field cron expressions run at minute granularity. Laravel handles sub-minute repetition through everyFiveSeconds(). This Compose setup uses a persistent schedule:work process, so it does not install an OS cron daemon or add a host crontab. The worker waits for the next minute boundary before starting its first schedule:run; that run continues until the minute ends to handle five-second tasks.

On a separate server that already runs the Laravel scheduler from cron, the conventional trigger is:

Code
* * * * * cd /path/to/application && php artisan schedule:run >> /dev/null 2>&1

That line is an explanation of the alternative, not a file installed by this repository. Do not also run that trigger against this demo while its Compose scheduler is running, or you can create duplicate executions.

Start the application

Use PHP 8.2+ and Docker Compose. Composer dependencies and frontend assets are prepared on the host, as in earlier lessons; the Dockerfile does not install Composer or Node.js.

The PHP services mount an anonymous vendor volume populated from the built image. This keeps Composer's thousands of files on the Docker filesystem, avoiding slow Windows bind-mount reads on each five-second command. Run composer install before building. If Composer dependencies change later, stop this stack with docker compose --profile scheduler down (without -v), then rebuild and start it again; newly created anonymous vendor volumes receive the rebuilt dependencies. The named MySQL volume is retained. Anonymous storage/cache volumes also belong to the local containers, so retain any needed local uploads/logs separately before recreating the entire stack.

For a fresh checkout:

Shell
cp .env.example .env
composer install
php artisan key:generate
npm ci
npm run build
docker compose config --quiet
docker compose up -d --build db web nginx
docker compose exec web php artisan config:clear
docker compose exec web php artisan migrate --force
docker compose exec nginx nginx -t

In PowerShell, use Copy-Item .env.example .env instead of cp if preferred. Do not overwrite an existing .env or regenerate an existing application key. When updating an existing checkout, install changed dependencies if needed, rebuild/recreate services, clear the container config and run migrations. The new migration adds an indexed boolean is_scheduler_demo, defaulting to false; existing users remain ordinary users.

Compose explicitly supplies Laravel's MySQL connection, including clearing DB_URL and DB_SOCKET, so the host's SQLite example does not accidentally direct container writes elsewhere. For host-side Artisan commands the original .env still applies; run migrations and this example inside Docker to use the Docker database.

Nginx defaults to http://localhost:8080; MySQL is reachable by a host client at 127.0.0.1:3308. Inside Docker use db:3306. If host ports are occupied, set APP_PORT and DB_FORWARD_PORT in your private .env, for example APP_PORT=8082 and DB_FORWARD_PORT=3309, then recreate the affected services. The dbdata volume is retained. Example root credentials are for this local tutorial; existing MySQL volumes retain their original initialization credentials.

If you recreate web while Nginx remains running and see a 502 response, run docker compose restart nginx so Nginx resolves the current PHP-FPM container address again.

Run and record the demo

Shell
# Optional: create exactly one user before starting the repeating schedule.
docker compose run --rm -e SCHEDULER_DEMO_ENABLED=true web php artisan demo:create-user

# Show the registered five-second task, then start the dedicated scheduler.
docker compose --profile scheduler run --rm scheduler php artisan schedule:list
docker compose --profile scheduler up -d scheduler
docker compose logs -f scheduler

Open http://localhost:8080/scheduler-demo (or your configured HTTP port). Wait for the next minute boundary after starting the worker, then rows should appear approximately every five seconds. This is not a hard real-time guarantee: execution time, machine load and refresh timing affect what appears on screen. The table shows 15 users per page and only rows created by this demo.

Command output is appended to the scheduler container's storage/logs/scheduler-demo.log:

Shell
docker compose exec scheduler tail -n 20 storage/logs/scheduler-demo.log

The table may contain repeated names because the random range is deliberately only 1–1000. Emails remain distinct. A direct demo:create-user invocation in web without the explicit environment opt-in fails without inserting a record.

Stop after recording

Shell
docker compose stop scheduler
docker compose --profile scheduler ps

Refresh the page manually to confirm the count stays stable. The scheduler creates roughly 12 users per minute while active, so stop it after the example. It is behind the scheduler profile and is not started by a normal docker compose up -d.

To stop the entire local stack while preserving MySQL data:

Shell
docker compose --profile scheduler down

Do not add -v if you want to retain the lesson's database. Recreate the scheduler after changing its code or environment; its restart: unless-stopped policy restarts a failed worker but respects an explicit stop.

Corrections carried forward from Lesson 3

  • Replaced the invalid listen 80. with listen 80;.
  • Restored $query_string in the front-controller fallback.
  • Added missing semicolons to FastCGI directives and deny all.
  • Restored a Docker build definition shared by web and scheduler.
  • Added MySQL readiness checking and explicit Laravel container DB settings.
  • Added an image-populated anonymous vendor volume for responsive PHP execution on Docker Desktop with a Windows source bind mount.
  • Removed the unused MYSQL_PASSWORD setting, which had no accompanying MYSQL_USER.
  • Added .dockerignore so local .env, Git history, generated publishing/video files and host Node dependencies are not copied into the image. Runtime source remains a bind mount, matching this local development architecture.
  • Used COPY --chown=www-data:www-data instead of recursively changing ownership of the entire copied project in later image layers; Laravel's writable directories retain mode 775.

The old lesson tags are immutable snapshots; these fixes belong to lesson-4, rather than rewriting lesson-3.

Checks

Shell
php vendor/bin/pest
php vendor/bin/pint --test
docker compose --profile scheduler config --quiet
docker compose exec nginx nginx -t

The feature tests use an in-memory SQLite database and cover command output data, password hashing, unique emails, opt-in behavior, the five-second schedule, demo-only visibility and pagination. Container smoke verification separately checks MySQL inserts and the actual scheduler timing.

The implemented command, schedule and database marker

These are the actual lesson files. The command performs one insert; Laravel supplies the repetition. The boolean marker lets the demo page list only these synthetic records without mixing in ordinary users.

app/Console/Commands/CreateTestUser.php

PHP
<?php

namespace App\Console\Commands;

use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Str;

class CreateTestUser extends Command
{
    protected $signature = 'demo:create-user';

    protected $description = 'Create one test user for the Lesson 4 scheduler demo';

    public function handle(): int
    {
        if (! config('scheduler-demo.enabled')) {
            $this->warn('Scheduler demo is disabled. Set SCHEDULER_DEMO_ENABLED=true to enable it.');

            return self::FAILURE;
        }

        $name = 'test'.random_int(1, 1000);
        $email = $name.'.'.now()->utc()->format('YmdHis').'.'.strtolower((string) Str::ulid()).'@example.com';

        $user = User::create([
            'name' => $name,
            'email' => $email,
            'password' => Str::random(40),
            'is_scheduler_demo' => true,
        ]);

        $this->info("Created user #{$user->id}: {$user->name} <{$user->email}>");

        return self::SUCCESS;
    }
}

config/scheduler-demo.php

PHP
<?php

return [
    'enabled' => env('SCHEDULER_DEMO_ENABLED', false),
];

routes/console.php

PHP
<?php

use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;

Artisan::command('inspire', function () {
    $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');

Schedule::command('demo:create-user')
    ->everyFiveSeconds()
    ->withoutOverlapping(1)
    ->when(fn () => config('scheduler-demo.enabled'))
    ->appendOutputTo(storage_path('logs/scheduler-demo.log'));

database/migrations/2026_10_16_000001_add_is_scheduler_demo_to_users_table.php

PHP
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->boolean('is_scheduler_demo')->default(false)->index();
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('is_scheduler_demo');
        });
    }
};

The existing User model hashes the password through its hashed cast. It now also includes is_scheduler_demo in $fillable and casts the marker to boolean.

Docker Compose: share PHP, separate the scheduler process

docker-compose.yaml

YAML
x-laravel-environment: &laravel-environment
  DB_URL: ""
  DB_SOCKET: ""
  DB_CONNECTION: mysql
  DB_HOST: db
  DB_PORT: 3306
  DB_DATABASE: laravel_app
  DB_USERNAME: root
  DB_PASSWORD: root

x-laravel-service: &laravel-service
  build:
    context: .
    dockerfile: Dockerfile
  image: my-laravel-app
  working_dir: /var/www/html
  user: "www-data"
  volumes:
    - .:/var/www/html
    - /var/www/html/vendor
    - /var/www/html/storage
    - /var/www/html/bootstrap/cache
  environment:
    <<: *laravel-environment
    SCHEDULER_DEMO_ENABLED: "false"
  depends_on:
    db:
      condition: service_healthy

services:
  web:
    <<: *laravel-service

  scheduler:
    <<: *laravel-service
    profiles: ["scheduler"]
    init: true
    command: ["php", "artisan", "schedule:work", "--no-interaction"]
    restart: unless-stopped
    environment:
      <<: *laravel-environment
      CACHE_STORE: file
      SCHEDULER_DEMO_ENABLED: "true"

  nginx:
    image: nginx:latest
    ports:
      - "${APP_PORT:-8080}:80"
    volumes:
      - .:/var/www/html:ro
      - ./.docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - web

  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: laravel_app
    ports:
      - "${DB_FORWARD_PORT:-3308}:3306"
    volumes:
      - dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -p\"$${MYSQL_ROOT_PASSWORD}\" --silent"]
      interval: 5s
      timeout: 5s
      retries: 20
      start_period: 20s

volumes:
  dbdata:

Dockerfile

Code
FROM php:8.2-fpm

WORKDIR /var/www/html

RUN apt-get update && apt-get install -y \
    libzip-dev \
    unzip \
    && docker-php-ext-install zip pdo_mysql \
    && rm -rf /var/lib/apt/lists/*

COPY --chown=www-data:www-data . /var/www/html

RUN chmod -R 775 /var/www/html/storage /var/www/html/bootstrap/cache

The corrected Nginx file

.docker/nginx/default.conf

Code
server {
    listen 80;
    index index.php index.html;
    root /var/www/html/public;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include fastcgi_params;
        fastcgi_pass web:9000;
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
        fastcgi_buffers 16 16k;
        fastcgi_buffer_size 32k;
    }

    location ~ /\.ht {
        deny all;
    }
}

A Blade page to see users appear

app/Http/Controllers/SchedulerDemoController.php

PHP
<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\View\View;

class SchedulerDemoController extends Controller
{
    public function __invoke(): View
    {
        $users = User::query()
            ->where('is_scheduler_demo', true)
            ->orderByDesc('id')
            ->paginate(15, ['id', 'name', 'email', 'created_at']);

        return view('scheduler-demo', compact('users'));
    }
}

The named GET route /scheduler-demo calls this controller. The resources/views/scheduler-demo.blade.php template escapes displayed values with Blade interpolation, renders the paginated rows and includes a five-second refresh checkbox plus a manual refresh button. It displays neither passwords nor regular user accounts.

Register the users view route: routes/web.php

This is the Lesson 4 route file. The named /scheduler-demo route calls the controller shown above and passes its paginated demo users to the Blade template.

PHP
<?php

use App\Http\Controllers\SchedulerDemoController;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    return view('welcome');
});

Route::get('/scheduler-demo', SchedulerDemoController::class)->name('scheduler-demo');

resources/views/scheduler-demo.blade.php

Create this Blade file with the complete Lesson 4 implementation below. It includes the demo-user count, ID/name/email/UTC columns, an empty state, pagination, manual refresh, and an optional five-second auto-refresh setting remembered for this browser session. Blade escapes the displayed user values.

HTML / XML
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Lesson 4 · Laravel Docker Scheduler</title>
    <style>
        :root { color-scheme: light; font-family: ui-sans-serif, system-ui, sans-serif; color: #142c40; background: #f5f7fa; }
        * { box-sizing: border-box; }
        body { margin: 0; }
        main { max-width: 1160px; margin: 64px auto; padding: 0 24px; }
        .eyebrow { color: #db3026; font-size: .8rem; font-weight: 750; letter-spacing: .14em; text-transform: uppercase; }
        h1 { font-size: clamp(2rem, 5vw, 3.1rem); line-height: 1.15; margin: 14px 0; letter-spacing: -.04em; }
        .intro { max-width: 720px; color: #526477; line-height: 1.7; }
        .summary { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; margin: 28px 0; }
        .count { background: #fff; border: 1px solid #dce4ed; border-radius: 14px; padding: 18px 24px; }
        .count strong { font-size: 2rem; margin-right: 8px; }
        .refresh { margin-left: auto; display: flex; align-items: center; flex-wrap: wrap; gap: 16px; font-size: .9rem; }
        button, .pagination a { color: #fff; background: #142c40; border: 0; border-radius: 8px; padding: 11px 16px; font: inherit; cursor: pointer; text-decoration: none; }
        .table-wrap { overflow-x: auto; border: 1px solid #dce4ed; border-radius: 14px; background: #fff; }
        table { border-collapse: collapse; width: 100%; text-align: left; font-size: .92rem; }
        th { background: #edf2f7; padding: 15px 20px; font-size: .75rem; letter-spacing: .06em; text-transform: uppercase; white-space: nowrap; }
        td { padding: 17px 20px; border-top: 1px solid #edf2f7; }
        td:first-child { color: #6a7b8b; }
        .name { font-weight: 700; }
        .email { overflow-wrap: anywhere; min-width: 270px; }
        .time { white-space: nowrap; color: #526477; }
        .empty { padding: 40px 24px; text-align: center; color: #526477; line-height: 1.8; }
        code { background: #edf2f7; padding: 3px 6px; border-radius: 4px; font-size: .85em; }
        .pagination { margin-top: 22px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
        .pagination span, footer { color: #526477; font-size: .85rem; }
        footer { margin-top: 30px; line-height: 1.7; }
        a:focus-visible, button:focus-visible, input:focus-visible { outline: 3px solid #2188d5; outline-offset: 4px; }
        @media (max-width: 640px) { main { margin-top: 32px; padding: 0 16px; } .refresh { margin-left: 0; } }
    </style>
</head>
<body>
<main>
    <div class="eyebrow">Laravel + Docker · Lesson 4</div>
    <h1>Watch the scheduler at work.</h1>
    <p class="intro">Each scheduled run creates one test user. Start the Docker scheduler service to see a new row approximately every five seconds. This page shows only users marked as part of this demo.</p>

    <section class="summary" aria-label="Demo summary">
        <div class="count"><strong>{{ number_format($users->total()) }}</strong> demo users</div>
        <div class="refresh">
            <label><input type="checkbox" id="auto-refresh" checked> Refresh every 5 seconds</label>
            <button type="button" id="refresh-now">Refresh now</button>
        </div>
    </section>

    <div class="table-wrap">
        <table aria-label="Users created by the Lesson 4 scheduler">
            <thead><tr><th scope="col">ID</th><th scope="col">Name</th><th scope="col">Email</th><th scope="col">Created (UTC)</th></tr></thead>
            <tbody>
            @forelse ($users as $user)
                <tr>
                    <td>{{ $user->id }}</td>
                    <td class="name">{{ $user->name }}</td>
                    <td class="email">{{ $user->email }}</td>
                    <td class="time"><time datetime="{{ $user->created_at->toIso8601String() }}">{{ $user->created_at->utc()->format('Y-m-d H:i:s') }}</time></td>
                </tr>
            @empty
                <tr><td colspan="4" class="empty">No demo users yet.<br>After running migrations, start <code>docker compose --profile scheduler up -d scheduler</code>.<br>The worker starts its first scheduled minute at the next minute boundary.</td></tr>
            @endforelse
            </tbody>
        </table>
    </div>

    <nav class="pagination" aria-label="Demo user pages">
        <div>@if ($users->previousPageUrl())<a href="{{ $users->previousPageUrl() }}">← Newer users</a>@endif</div>
        <span>Page {{ $users->currentPage() }} of {{ $users->lastPage() }}</span>
        <div>@if ($users->nextPageUrl())<a href="{{ $users->nextPageUrl() }}">Older users →</a>@endif</div>
    </nav>
    <footer>Stop the demo when finished: <code>docker compose stop scheduler</code>.<br>These are synthetic addresses at example.com. The command does not send email.</footer>
</main>
<script>
    const refreshPreference = sessionStorage.getItem('scheduler-demo-auto-refresh') !== 'off';
    const autoRefresh = document.getElementById('auto-refresh');
    autoRefresh.checked = refreshPreference;
    autoRefresh.addEventListener('change', () => {
        sessionStorage.setItem('scheduler-demo-auto-refresh', autoRefresh.checked ? 'on' : 'off');
    });
    document.getElementById('refresh-now').addEventListener('click', () => window.location.reload());
    window.setInterval(() => {
        if (autoRefresh.checked && !document.hidden) window.location.reload();
    }, 5000);
</script>
</body>
</html>

Open http://localhost:8080/scheduler-demo, using your configured APP_PORT if different. Start the optional scheduler to create rows and pause auto-refresh when inspecting users or navigating pages. Stop user creation after the demonstration with docker compose stop scheduler.

Lesson 4 Blade page showing six test users created by the Docker scheduler
The verified local demo after six users were created. The scheduler is stopped; refresh can be paused while inspecting the rows.

What was verified

  • The PHP image built successfully and Docker Compose accepted the configuration.
  • MySQL migrations completed in an isolated local Docker project.
  • Nginx passed nginx -t and the Blade endpoint returned HTTP 200.
  • Eight tests passed with 31 assertions, including command behavior, unique emails, disabled runs, scheduler registration, demo-only visibility and pagination.
  • Four additional users were observed during the timed verification window. Their successive insert intervals were 4.0, 5.0, 5.0 seconds; the median was five seconds.
  • The scheduler was stopped and the user count stayed stable. No production user database was used for the example.

References

Article Index & Topic Tags
MysqlDockerLaravelPhpNginxLaravel Docker LessonsSchedulerCron JobsBlade

YouTube Channel

Build Along With Me

Practical walkthroughs on Laravel, React, APIs and deployment workflows.

Muhammad Yasir Amin
Available for Contracts
Author & Solutions Architect

Senior Full Stack Engineer

Independent Senior Full Stack Engineer working on Laravel, React and Next.js applications, APIs and delivery workflows.

Laravel & PHP ApplicationsReact & Next.js InterfacesREST APIs & IntegrationsDatabase Queries & DebuggingDocker Development WorkflowsClear Scope & Handover
Discuss Your Next Step

What Does Your Application Need Next?

Share your current system, the problem you want to solve and what a useful result looks like. We can identify the right starting point and agree on a practical scope.

Technical Audit

Find the Next Useful Fix

Application Development

Scoped, Reviewable Milestones

Ongoing Support

Agreed Capacity and Priorities

Share your goals, current stack and requirements before the call.