php 112 lines · 4 tabs

Recurring Cleanup Task with Symfony Scheduler and Messenger Handler

Shared by codesnips Sep 2026
4 tabs
<?php

namespace App\Message;

final class PurgeExpiredTokensMessage
{
    public function __construct(
        public readonly int $batchSize = 500,
    ) {
    }
}
4 files · php Explain with highlit

This snippet shows how Symfony's Scheduler component drives a recurring maintenance job that is executed through Messenger, keeping the when (scheduling) cleanly separated from the what (the actual work). The core idea of Scheduler is that a RecurringMessage is a plain message wrapped with a trigger (a cron expression or a fixed interval); when the trigger fires, the message is dispatched onto a transport and handled asynchronously like any other Messenger message. This decoupling means the cleanup logic is testable and reusable independent of its schedule, and the schedule itself lives in code under version control rather than in a crontab.

In PurgeExpiredTokensMessage, the message is a trivial immutable DTO carrying a batchSize. It intentionally holds no logic — messages in Messenger are data, not behavior. Carrying batchSize on the message lets the same handler be reused with different batch sizes if another schedule dispatches it.

In CleanupSchedule, the class implements ScheduleProviderInterface and is tagged with #[AsSchedule('default')] so the default scheduler transport picks it up. The getSchedule() method builds a RecurringMessage::cron('0 3 * * *', ...) to run every day at 03:00, plus a second RecurringMessage::every('6 hours', ...) to demonstrate combining triggers. The schedule is memoized in $this->schedule because getSchedule() may be called repeatedly. stateful($this->cache) persists the last run so missed executions (for example after downtime) can be caught up rather than silently skipped, and lock($this->lockFactory->createLock(...)) prevents two workers from firing the same trigger concurrently.

In PurgeExpiredTokensHandler, the #[AsMessageHandler] attribute registers the handler, and __invoke() does the real work: it deletes expired tokens in bounded batches via the repository, looping until fewer than batchSize rows remain so a single run drains the backlog without loading everything into memory. Batching bounds memory and lock duration, and the delete-by-query avoids hydrating entities. To actually process these, a worker must consume the transport with messenger:consume scheduler_default, and the trade-off to remember is that nothing runs unless that consumer is alive — the schedule is only as reliable as the process supervising it.


Related snips

ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
rust
use clap::Parser;

#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
    #[arg(short, long)]

clap for CLI argument parsing with derive macros

rust cli clap
by Marcus Chen 1 tab
ruby
class CreateTopSellersMv < ActiveRecord::Migration[7.0]
  def up
    execute <<~SQL
      CREATE MATERIALIZED VIEW top_sellers AS
        SELECT p.id            AS product_id,
               p.name          AS product_name,

Cache-Friendly “Top N” with Materialized View Refresh

rails postgres performance
by codesnips 4 tabs
ruby
class CreateDeadJobs < ActiveRecord::Migration[7.1]
  def change
    create_table :dead_jobs do |t|
      t.string  :jid, null: false
      t.string  :queue, null: false
      t.string  :klass, null: false

Background Job Dead Letter Queue (DLQ) Table

rails reliability background-jobs
by codesnips 4 tabs
typescript
type AsyncTask = () => Promise<void>;

export interface GuardOptions {
  name: string;
  logger?: Pick<Console, "info" | "warn" | "error">;
}

Cron scheduling with node-cron (with guard)

reliability node-cron cron
by codesnips 3 tabs
rust
use anyhow::{Context, Result};
use std::fs;

fn load_config(path: &str) -> Result<String> {
    fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {}", path))

anyhow::Context for adding error context without custom types

rust error-handling cli
by Marcus Chen 1 tab

Share this code

Here's the card — post it anywhere.

Recurring Cleanup Task with Symfony Scheduler and Messenger Handler — share card
Link copied