<?php
namespace App\Message;
final class PurgeExpiredTokensMessage
{
public function __construct(
public readonly int $batchSize = 500,
) {
}
}
<?php
namespace App\Scheduler;
use App\Message\PurgeExpiredTokensMessage;
use Symfony\Component\Scheduler\Attribute\AsSchedule;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Component\Lock\LockFactory;
use Symfony\Contracts\Cache\CacheInterface;
#[AsSchedule('default')]
final class CleanupSchedule implements ScheduleProviderInterface
{
private ?Schedule $schedule = null;
public function __construct(
private readonly CacheInterface $cache,
private readonly LockFactory $lockFactory,
) {
}
public function getSchedule(): Schedule
{
return $this->schedule ??= (new Schedule())
->add(
RecurringMessage::cron('0 3 * * *', new PurgeExpiredTokensMessage(1000)),
RecurringMessage::every('6 hours', new PurgeExpiredTokensMessage(500)),
)
->stateful($this->cache)
->lock($this->lockFactory->createLock('cleanup-schedule'));
}
}
<?php
namespace App\MessageHandler;
use App\Message\PurgeExpiredTokensMessage;
use App\Repository\ApiTokenRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class PurgeExpiredTokensHandler
{
public function __construct(
private readonly ApiTokenRepository $tokens,
private readonly LoggerInterface $logger,
) {
}
public function __invoke(PurgeExpiredTokensMessage $message): void
{
$total = 0;
do {
$deleted = $this->tokens->deleteExpiredBatch($message->batchSize);
$total += $deleted;
} while ($deleted === $message->batchSize);
$this->logger->info('Purged expired API tokens', ['count' => $total]);
}
}
<?php
namespace App\Repository;
use App\Entity\ApiToken;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class ApiTokenRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ApiToken::class);
}
public function deleteExpiredBatch(int $limit): int
{
$conn = $this->getEntityManager()->getConnection();
$sql = <<<SQL
DELETE FROM api_token
WHERE id IN (
SELECT id FROM api_token
WHERE expires_at < :now
ORDER BY expires_at ASC
LIMIT :limit
)
SQL;
return (int) $conn->executeStatement($sql, [
'now' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
'limit' => $limit,
], [
'limit' => \PDO::PARAM_INT,
]);
}
}
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
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
use clap::Parser;
#[derive(Parser, Debug)]
#[command(author, version, about)]
struct Args {
#[arg(short, long)]
clap for CLI argument parsing with derive macros
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
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
type AsyncTask = () => Promise<void>;
export interface GuardOptions {
name: string;
logger?: Pick<Console, "info" | "warn" | "error">;
}
Cron scheduling with node-cron (with guard)
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
Share this code
Here's the card — post it anywhere.