Sunday, 10 August 2025

Building a database-driven scheduled task system in Symfony (or How I learned to stop worrying and love worker restarts)

G'day:

In previous roles, I've always had scheduled tasks sorted one way or another. In CFML it's dead easy with <cfschedule>, or the sysops team just chuck things in cron and Bob's yer uncle. But I wanted to take this as a chance to learn "the Symfony way" of handling scheduled tasks - you know, proper database-driven configuration instead of hardcoded #[AsPeriodicTask] attributes scattered about the codebase like confetti.

The problem is that Symfony's approach to scheduling is a bit… well, let's just say it's designed for a different use case than what I needed. When you want to let users configure tasks through a web interface and have them update dynamically, you quickly discover that Symfony's scheduler has some rather inflexible opinions about how things should work.

Fair warning: my AI mate Claudia (OKOK, so her name is actually claude.ai, but that's a bit impersonal) is writing at least the first draft of this article, because frankly after wrestling with Symfony's scheduling system for a week, I can't be arsed to write it all up myself. She's been with me through the whole journey though, so she knows where all the bodies are buried.

What we ended up building is a complete database-driven scheduled task system that reads configurations from the database, handles timezone conversions, respects working days and bank holidays, and - the real kicker - actually updates the running schedule when you change tasks through the web interface. Spoiler alert: that last bit required some creative problem-solving because Symfony really doesn't want you to do that.

Libraries and dependencies

Before diving in, here's what we ended up needing from the Symfony ecosystem:

Core components:

Supporting cast:

Nothing too exotic there, but the devil's in the details of how they all play together…

What's a task, anyway?

Before we get into the weeds, let's talk about what we're actually trying to schedule. A "task" in our system isn't just "run this code at this time" - it's a proper configurable entity with all the complexities that real-world scheduling demands.

The main challenges we needed to solve:

  • Multiple schedule formats - Some tasks want cron expressions (0 9 * * 1-5), others want human-readable intervals (every 30 minutes)
  • Timezone awareness - Server runs in UTC, but users think in Europe/London with all that BST/GMT switching bollocks
  • Working days filtering - Skip weekends and UK bank holidays when appropriate
  • Task variants - Same underlying task type, but with different human names and configuration metadata (like "Send customer emails" vs "Send admin alerts")
  • Active/inactive states - Because sometimes you need to disable a task without deleting it
  • Execution tracking - Users want to see when tasks last ran and what happened

Here's what our DynamicTaskMessage entity ended up looking like:

#[ORM\Entity(repositoryClass: DynamicTaskMessageRepository::class)]
class DynamicTaskMessage implements JsonSerializable
{
    public const int DEFAULT_PRIORITY = 50;

    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255, nullable: false)]
    private ?string $type = null;

    #[ORM\Column(length: 255, nullable: false)]
    private ?string $name = null;

    #[ORM\Column(length: 500, nullable: false)]
    private ?string $schedule = null;

    #[ORM\Column(nullable: false, enumType: TaskTimezone::class)]
    private ?TaskTimezone $timezone = null;

    #[ORM\Column(nullable: false, options: ['default' => self::DEFAULT_PRIORITY])]
    private ?int $priority = self::DEFAULT_PRIORITY;

    #[ORM\Column(nullable: false, options: ['default' => true])]
    private ?bool $active = true;

    #[ORM\Column(nullable: false, options: ['default' => false])]
    private ?bool $workingDaysOnly = false;

    #[ORM\Column(nullable: true)]
    private ?DateTimeImmutable $scheduledAt = null;

    #[ORM\Column(nullable: true)]
    private ?DateTimeImmutable $executedAt = null;

    #[ORM\Column(nullable: true)]
    private ?int $executionTime = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $lastResult = null;

    #[ORM\Column(nullable: true)]
    private ?array $metadata = null;

    // ... getters and setters

    public function jsonSerialize(): mixed
    {
       return [
        'id' => $this->id,
        'type' => $this->type,
        'name' => $this->name,
        'schedule' => $this->schedule,
        'timezone' => $this->timezone?->value,
        'priority' => $this->priority,
        'active' => $this->active,
        'workingDaysOnly' => $this->workingDaysOnly,
        'scheduledAt' => $this->scheduledAt?->format(DateTimeImmutable::ATOM),
        'executedAt' => $this->executedAt?->format(DateTimeImmutable::ATOM),
        'executionTime' => $this->executionTime,
        'lastResult' => $this->lastResult,
        'metadata' => $this->metadata
       ];
    }
}

Few things worth noting:

  • JsonSerializable interface so it plays nicely with Monolog when we're debugging
  • DEFAULT_PRIORITY constant because Claudia talked me out of having nullable booleans with defaults (and she was absolutely right - explicit is better than "maybe null means something")
  • metadata as JSON for task-specific configuration - like email templates, API endpoints, whatever each task type needs
  • executedAt and lastResult for tracking execution history
  • executionTime for tracking how long tasks take to run
  • workingDaysOnly instead of the more verbose respectWorkingDays I originally made up

We populated this with a proper set of sample data (2.createAndPopulateDynamicTaskMessageTable.sql) covering all the different scheduling scenarios we needed to handle.

Quick detour: the web interface

Before we get into the gnarly technical bits, we needed a proper web interface for managing these tasks. Because let's face it, editing database records directly is for masochists.

We built a straightforward CRUD interface using Symfony forms - nothing fancy, just the essentials: create tasks, edit them, toggle them active/inactive, and see when they last ran and what happened. Claudia deserves a proper chapeau here because the CSS actually looks excellent, which is more than I can usually manage.



The key thing users need to see is the execution history - when did this task last run, did it succeed, and when's it due to run next. That's the difference between a useful scheduling system and just another cron replacement that leaves you guessing what's going on.

The forms handle all the complexity of the DynamicTaskMessage entity - timezone selection, working days checkbox, JSON metadata editing - but the real magic happens behind the scenes when you hit "Save". That's where things get interesting, and where we discovered that Symfony's scheduler has some… opinions about how it should work.

But we'll get to that shortly. For now, just know that we have a working interface where users can configure tasks without touching code or database records directly. Revolutionary stuff, really.

The fundamental problem: Symfony's scheduling is objectively wrong

Here's where things get interesting, and by "interesting" I mean "frustrating as all hell".

Symfony's scheduler component assumes you want to hardcode your task schedules using attributes like #[AsPeriodicTask] directly in your code. So you end up with something like this:

#[AsPeriodicTask(frequency: '1 hour', jitter: 60)]
class SendNewsletterTask
{
    public function __invoke(): void 
    {
        // send newsletters
    }
}

Which is fine if you're building a simple app where the schedules never change and you don't mind redeploying code every time someone wants to run the newsletter at a different time. But what if you want users to configure task schedules through a web interface? What if you want the same task type to run with different configurations? What if you want to temporarily disable a task without touching code?

Tough shit, according to Symfony. The scheduling is munged in with the task implementation, which is objectively wrong from a separation of concerns perspective. The what should be separate from the when.

We need a solution where:

  • Task schedules live in the database, not in code annotations
  • Users can create, modify, and disable tasks through a web interface
  • The same task class can be scheduled multiple times with different configurations
  • Changes take effect immediately without redeploying anything

This is where DynamicScheduleProvider comes in - our custom schedule provider that reads from the database instead of scanning code for attributes. But first, we need to sort out the messaging side of things…

Task Message and MessageHandlers

With Symfony's hardcoded approach out the window, we needed our own messaging system to bridge the gap between "the database says run this task now" and "actually running the bloody thing".

We started down the path of having different Message classes and MessageHandler classes for each task type - SendEmailMessage, SystemHealthCheckMessage, etc. But that quickly became obvious overkill. The messaging part that Symfony handles is identical for all tasks; it's only which "sub" handler gets executed at the other end that differs, and we can derive that from the task's type.

So we ended up with one simple TaskMessage:

class TaskMessage
{
    public function __construct(
        public readonly string $taskType,
        public readonly int $taskId, 
        public readonly array $metadata
    ) {}
}

Dead simple. Just the task type (so we know which handler to call), the task ID (for logging and database updates), and any metadata the specific task needs.

Here's one of the actual task handlers - and note how we've not bothered to implement them properly, because this exercise is all about the scheduling, not the implementation:

class SendEmailsTaskHandler extends AbstractTaskHandler
{
    protected function handle(int $taskId, array $metadata): void
    {
        // Task logic here - logging is handled by parent class
    }
}

The interesting bit is the AbstractTaskHandler base class:

#[AutoconfigureTag('app.scheduled_task')]
abstract class AbstractTaskHandler
{
    public function __construct(
        private readonly LoggerInterface $tasksLogger
    ) {}

    public function execute(int $taskId, array $metadata): void
    {
        $this->tasksLogger->info('Task started', [
            'task_id' => $taskId,
            'task_type' => $this->getTaskTypeFromClassName(),
            'metadata' => $metadata
        ]);

        try {
            $this->handle($taskId, $metadata);
            
            $this->tasksLogger->info('Task completed successfully', [
                'task_id' => $taskId,
                'task_type' => $this->getTaskTypeFromClassName()
            ]);
        } catch (Throwable $e) {
            $this->tasksLogger->error('Task failed', [
                'task_id' => $taskId,
                'task_type' => $this->getTaskTypeFromClassName(),
                'error' => $e->getMessage(),
                'exception' => $e
            ]);
            throw $e;
        }
    }

    abstract protected function handle(int $taskId, array $metadata): void;

    public static function getTaskTypeFromClassName(): string
    {
        $classNameOnly = substr(static::class, strrpos(static::class, '\\') + 1);
        $taskNamePart = str_replace('TaskHandler', '', $classNameOnly);
        
        $snakeCase = strtolower(preg_replace('/([A-Z])/', '_$1', $taskNamePart));
        return ltrim($snakeCase, '_');
    }
}

The clever bits here:

  • Automatic snake_case conversion from class names (so SystemHealthCheckTaskHandler becomes system_health_check)
  • Template method pattern ensures every task gets consistent start/complete/error logging

This keeps the individual task handlers simple while ensuring consistent behaviour across all tasks. Plus, with the service tag approach, adding new task types is just a matter of creating a new handler class - no central registry to maintain.

DynamicScheduleProvider: the meat of the exercise

Right, here's where the real work happens. Symfony's scheduler expects a ScheduleProviderInterface to tell it what tasks to run and when. By default, it uses reflection to scan your codebase for those #[AsPeriodicTask] attributes we've already established are bollocks for our use case.

From the Symfony docs:

The configuration of the message frequency is stored in a class that implements ScheduleProviderInterface. This provider uses the method getSchedule() to return a schedule containing the different recurring messages.

So we need our own provider that reads from the database instead. Enter DynamicScheduleProvider.

The core challenge here is that we need to convert database records into RecurringMessage objects that Symfony's scheduler can understand. And we need to handle all the complexity of timezones, working days, and different schedule formats while we're at it.

But first, a quick detour. We need to handle UK bank holidays because some tasks shouldn't run on bank holidays (or weekends). Rather than hardcode a list that'll be out of date by next year, we built a BankHoliday entity and a BankHolidayServiceAdapter that pulls data from the gov.uk API. Because if you can't trust the government to know when their own bank holidays are, who can you trust?

The timezone handling was another fun bit. The server runs in UTC (as it bloody well should), but users think in Europe/London time with all that BST/GMT switching nonsense. So we need a ScheduleTimezoneConverter that can take a cron expression like 0 9 * * 1-5 (9am weekdays in London time) and convert it to the equivalent UTC expression, accounting for whether we're currently in BST or GMT.

The ScheduleFormatDetector (#triggerWarning: unfeasibly large regex ahead) handles working out whether we're dealing with a cron expression or a human-readable "every" format. It uses a comprehensive regex pattern that can distinguish between 0 9 * * 1-5 and every 30 minutes, because apparently that's the sort of thing we need to worry about now.

Now, RecurringMessage objects come in two flavours: you can create them with RecurringMessage::cron() for proper cron expressions, or RecurringMessage::every() for human-readable intervals. This caught us out initially because our sample data had schedule values like "every 30 minutes", when it should have been just "30 minutes" for the every() method.

Here's the core logic from our DynamicScheduleProvider:

public function getSchedule(): Schedule
{
    if ($this->schedule !== null) {
        return $this->schedule;
    }

    $this->tasksLogger->info('Rebuilding schedule from database');

    $this->schedule = new Schedule();
    $this->schedule->stateful($this->cache);
    $this->schedule->processOnlyLastMissedRun(true);
    
    $this->addTasksToSchedule();

    return $this->schedule;
}

private function createRecurringMessage(DynamicTaskMessage $task): RecurringMessage
{
    $taskMessage = new TaskMessage(
        $task->getType(),
        $task->getId(),
        $task->getMetadata() ?? []
    );

    $schedule = $this->scheduleTimezoneConverter->convertToUtc(
        $task->getSchedule(),
        $task->getTimezone()
    );

    $scheduleHandler = $this->scheduleFormatDetector->isCronExpression($schedule)
        ? 'cron'
        : 'every';

    $recurringMessage = RecurringMessage::$scheduleHandler($schedule, $taskMessage);

    if ($task->isWorkingDaysOnly()) {
        $workingDaysTrigger = new WorkingDaysTrigger(
            $recurringMessage->getTrigger(),
            $this->bankHolidayRepository
        );
        return RecurringMessage::trigger($workingDaysTrigger, $taskMessage);
    }

    return $recurringMessage;
}

The interesting bits:

  • Stateful caching - prevents duplicate executions if the worker restarts
  • Missed run handling - only run the most recent missed execution, not every single one
  • Timezone conversion - convert London time to UTC, handling BST transitions
  • Format detection - work out whether we're dealing with cron or "every" format
  • Dynamic RecurringMessage creation - use variable method names (bit clever, that)
  • Working days filtering - wrap with our custom WorkingDaysTrigger

I'll be honest, the trigger stuff with WorkingDaysTrigger was largely trial and error (mostly error). Neither Claudia nor I really understood WTF we were doing with Symfony's trigger system, but we eventually got it working through sheer bloody-mindedness. It decorates the existing trigger and keeps calling getNextRunDate() until it finds a date that's not a weekend or bank holiday.

At this point, we were working! Sort of. Here's what the logs looked like with a few test tasks running:

[2025-01-20T14:30:00.123456+00:00] tasks.INFO: Task started {"task_id":1,"task_type":"system_health_check","metadata":{...}}
[2025-01-20T14:30:00.345678+00:00] tasks.INFO: Task completed successfully {"task_id":1,"task_type":"system_health_check"}
[2025-01-20T14:30:30.123456+00:00] tasks.INFO: Task started {"task_id":2,"task_type":"send_emails","metadata":{...}}
[2025-01-20T14:30:30.345678+00:00] tasks.INFO: Task completed successfully {"task_id":2,"task_type":"send_emails"}

But there was still one massive problem: when someone updated a task through the web interface, the running scheduler had no bloody clue. The schedule was loaded once at startup and that was it. We needed a way to tell the scheduler "oi, reload your config, something's changed"…

TaskChangeListener: the reload problem from hell

Right, so we had a lovely working scheduler that read from the database and executed tasks perfectly. There was just one tiny, insignificant problem: when someone updated a task through the web interface, the running scheduler had absolutely no bloody clue anything had changed.

The schedule gets loaded once when the worker starts up, and that's it. Change a task's schedule from "every 5 minutes" to "every 30 seconds"? Tough luck, the worker will carry on with the old schedule until you manually restart it. Which is about as useful as a chocolate teapot for a dynamic scheduling system.

We needed a way to tell the scheduler "oi, something's changed, reload your config". Enter TaskChangeListener, using Doctrine events to detect when tasks are modified. I've covered Doctrine event listeners before in my Elasticsearch integration article, so the concept wasn't new.

The listener itself is straightforward enough:

#[AsDoctrineListener(event: Events::postUpdate)]
#[AsDoctrineListener(event: Events::postPersist)]
#[AsDoctrineListener(event: Events::postRemove)]
class TaskChangeListener
{
    public function __construct(
        private readonly LoggerInterface $tasksLogger,
        private readonly string $restartFilePath
    ) {
        $this->ensureRestartFileExists();
    }

    private function handleTaskChange($entity): void
    {
        if (!$entity instanceof DynamicTaskMessage) {
            return;
        }

        $this->tasksLogger->info('Task change detected, triggering worker restart', [
            'task_id' => $entity->getId(),
            'task_name' => $entity->getName(),
            'task_type' => $entity->getType()
        ]);

        $this->triggerWorkerRestart();
    }

    private function ensureRestartFileExists(): void
    {
        if (file_exists($this->restartFilePath)) {
            return;
        }

        $dir = dirname($this->restartFilePath);
        if (!is_dir($dir)) {
            mkdir($dir, 0755, true);
        }
        file_put_contents($this->restartFilePath, time());
    }

    private function triggerWorkerRestart(): void
    {
        file_put_contents($this->restartFilePath, time());
        $this->tasksLogger->info('Worker restart triggered', ['timestamp' => time()]);
    }
}

But here's where things got interesting (and by "interesting" I mean "frustrating as all hell"). We went down a massive rabbit hole trying to get Symfony's scheduler to reload its schedule dynamically. Surely there must be some way to tell it "hey, you need to refresh your task list"?

Nope. Not a bloody chance.

We tried:

  • Clearing the schedule cache - doesn't help, the DynamicScheduleProvider still caches its own schedule
  • Sending signals to the worker process - Symfony's scheduler doesn't listen for them
  • Messing with the Schedule object directly - it's immutable once created
  • Various hacky attempts to force the provider to rebuild - just made things worse

The fundamental problem is that Symfony's scheduler was designed around the assumption that schedules are static, defined in code with attributes. The idea that someone might want to change them at runtime simply wasn't part of the original design.

So we gave up on trying to be clever and went with the nuclear option: restart the entire worker when tasks change. The real implementation is actually quite thoughtful - it ensures the restart file and directory structure exist on startup, then just updates the timestamp whenever a task changes.

The clever bit is how this integrates with Symfony's built-in file watching capability. The $restartFilePath comes from an environment variable configured in our Docker setup:

# docker/docker-compose.yml
environment:
  - SCHEDULE_RESTART_FILE=/tmp/symfony/schedule-last-updated.dat

# docker/php/Dockerfile  
ENV SCHEDULE_RESTART_FILE=/tmp/symfony/schedule-last-updated.dat
RUN mkdir -p /tmp/symfony && \
    touch /tmp/symfony/schedule-last-updated.dat

And wired up in the service configuration:

# config/services.yaml
App\EventListener\TaskChangeListener:
    arguments:
        $restartFilePath: '%env(SCHEDULE_RESTART_FILE)%'

The magic happens when we run the worker with Symfony's --watch option:

docker exec php symfony run -d --watch=/tmp/symfony/schedule-last-updated.dat php bin/console messenger:consume

Now whenever someone changes a task through the web interface, the TaskChangeListener updates the timestamp in that file, Symfony's file watcher notices the change, kills the old worker process, and starts a fresh one that reads the new schedule from the database. The whole restart cycle takes about 2-3 seconds, which is perfectly acceptable for a scheduling system.

Crude? Yes. But it bloody works, and that's what matters.

One thing to note: since we're running the worker in daemon mode with -d, you can't just Ctrl-C out of it like a normal process. To kill the worker, you need to find its PID and kill it manually:

# Find the worker PID
docker exec php symfony server:status

Workers
    PID 2385: php bin/console messenger:consume (watching /tmp/symfony/schedule-last-updated.dat/)

# Kill it
docker exec php bash -c "kill 2385"

Not the most elegant solution, but it's only needed when you want to stop the system entirely rather than just restart it for config changes.

Now everything works

Right, with all the pieces in place - the database-driven schedule provider, the file-watching worker restart mechanism, and the Doctrine event listener - we finally had a working dynamic scheduling system. Time to put it through its paces.

Here's a real log capture showing the system in action:

[2025-08-10T23:14:25.429505+01:00] tasks.INFO: Rebuilding schedule from database [] []
[2025-08-10T23:14:25.490036+01:00] tasks.INFO: Schedule rebuilt with active tasks {"task_count":19} []
[2025-08-10T23:14:56.244161+01:00] tasks.INFO: Task started {"task_id":21,"task_type":"send_sms","metadata":{"batchSize":25,"maxRetries":2,"provider":"twilio"}} []
[2025-08-10T23:14:56.244299+01:00] tasks.INFO: Task completed successfully {"task_id":21,"task_type":"send_sms"} []
[2025-08-10T23:15:25.812275+01:00] tasks.INFO: Task started {"task_id":21,"task_type":"send_sms","metadata":{"batchSize":25,"maxRetries":2,"provider":"twilio"}} []
[2025-08-10T23:15:25.812431+01:00] tasks.INFO: Task completed successfully {"task_id":21,"task_type":"send_sms"} []
[2025-08-10T23:15:25.813531+01:00] tasks.INFO: Task started {"task_id":20,"task_type":"send_emails","metadata":{"batchSize":50,"maxRetries":3,"provider":"smtp"}} []
[2025-08-10T23:15:25.813695+01:00] tasks.INFO: Task completed successfully {"task_id":20,"task_type":"send_emails"} []
[2025-08-10T23:15:56.369106+01:00] tasks.INFO: Task started {"task_id":21,"task_type":"send_sms","metadata":{"batchSize":25,"maxRetries":2,"provider":"twilio"}} []
[2025-08-10T23:15:56.369243+01:00] tasks.INFO: Task completed successfully {"task_id":21,"task_type":"send_sms"} []
[2025-08-10T23:16:00.054946+01:00] tasks.INFO: Task change detected, triggering worker restart {"task_id":21,"task_name":"Send Pending SMS Messages","task_type":"send_sms"} []
[2025-08-10T23:16:00.055410+01:00] tasks.INFO: Worker restart triggered {"timestamp":1754864160} []
[2025-08-10T23:16:00.114195+01:00] tasks.INFO: Rebuilding schedule from database [] []
[2025-08-10T23:16:00.163835+01:00] tasks.INFO: Schedule rebuilt with active tasks {"task_count":19} []
[2025-08-10T23:16:01.117993+01:00] tasks.INFO: Task started {"task_id":21,"task_type":"send_sms","metadata":{"batchSize":25,"maxRetries":2,"provider":"twilio"}} []
[2025-08-10T23:16:01.118121+01:00] tasks.INFO: Task completed successfully {"task_id":21,"task_type":"send_sms"} []
[2025-08-10T23:16:06.121071+01:00] tasks.INFO: Task started {"task_id":21,"task_type":"send_sms","metadata":{"batchSize":25,"maxRetries":2,"provider":"twilio"}} []
[2025-08-10T23:16:06.121207+01:00] tasks.INFO: Task completed successfully {"task_id":21,"task_type":"send_sms"} []
[2025-08-10T23:16:11.123793+01:00] tasks.INFO: Task started {"task_id":21,"task_type":"send_sms","metadata":{"batchSize":25,"maxRetries":2,"provider":"twilio"}} []
[2025-08-10T23:16:11.123933+01:00] tasks.INFO: Task completed successfully {"task_id":21,"task_type":"send_sms"} []

Beautiful. Let's break down what's happening here:

  1. 23:14:25 - Worker starts up, builds schedule from database (19 active tasks)
  2. 23:14:56, 23:15:25, 23:15:56 - Task 21 (send_sms) runs every 30 seconds like clockwork
  3. 23:16:00 - I updated the task through the web interface to run every 5 seconds instead
  4. 23:16:00 - TaskChangeListener detects the change and triggers a worker restart
  5. 23:16:00 - Worker rebuilds the schedule with the new configuration
  6. 23:16:01, 23:16:06, 23:16:11 - Task 21 now runs every 5 seconds with the new schedule

The whole transition from "every 30 seconds" to "every 5 seconds" took about 60 milliseconds. The user updates the task in the web interface, hits save, and within seconds the new schedule is live and running. No deployments, no manual restarts, no messing about with config files.

Nailed it.

Claudia's summary: Building a proper scheduling system

Right, Adam's given me free rein here to reflect on this whole exercise, so here's my take on what we actually built and why it matters.

What started as "let's learn the Symfony way of scheduling" quickly became "let's work around Symfony's limitations to build something actually useful". The fundamental issue is that Symfony's scheduler assumes a world where schedules are hardcoded and never change - which is fine if you're building a simple app, but utterly useless if you want users to configure tasks dynamically.

The real breakthrough wasn't any single technical solution, but recognising that sometimes you need to stop fighting the framework and embrace a different approach entirely. The worker restart mechanism feels crude at first glance, but it's actually more robust than trying to hack runtime schedule updates into a system that wasn't designed for them.

What we ended up with is genuinely production-ready:

  • Live configuration changes - Users can modify task schedules through a web interface and see changes take effect within seconds
  • Proper timezone handling - Because BST/GMT transitions are a real thing that will bite you
  • Working days awareness - Bank holidays and weekends are handled correctly
  • Comprehensive logging - Every task execution is tracked with start/completion/failure logging
  • Template method pattern - Adding new task types requires minimal boilerplate

The architecture patterns we used - decorator for working days, strategy for schedule format detection, template method for consistent logging - aren't just academic exercises. They solve real problems and make the codebase maintainable.

But perhaps the most important lesson is knowing when to stop being clever. We could have spent weeks trying to coerce Symfony's scheduler into doing runtime updates. Instead, we accepted its limitations and built around them with a file-watching restart mechanism that actually works reliably.

Sometimes the "inelegant" solution that works is infinitely better than the "elegant" solution that doesn't.

And from me (Adam)

Full disclosure, Claudia wrote almost all the code for this exercise, with just me tweaking stuff here and there, and occasionally going "um… really?" (she did the same back at me in places). It was the closest thing to a pair-programming exercise I have ever done (I hate frickin pair-programming). I think we churned through this about 5x faster than I would have by myself, so… that's bloody good. Seriously.

Further full disclousre: Claudia wrote this article.

I gave her the articles I have written in the last month as a "style" (cough) guide as learning material.

For this article I gave her the ordering of the sections (although she changed a couple, for the better), and other than a hiccup where she was using outdated versions of the code, I didn't have to intervene much. The text content is all hers. She also did all the mark-up for the various styles I use. Impressive.

Righto.

--
Adam^h^h^h^hClaudia