Configuring cron in Symfony: A Complete Guide to Task Scheduling
LM
LmaDev
~/blog/configuring-cron-symfony-complete-guide Use Cases / Best Practices

Configuring cron in Symfony: A Complete Guide to Task Scheduling

Correctly configuring cron jobs in Symfony applications may seem trivial; all you have to do is create a class that inherits from Command and add the job to the crontab. However, the reality isn't so rosy, and after deploying the application to production, it turns out that not everything works as planned.

In this guide, I'll show you how to properly configure cron jobs in Symfony to minimize the so-called "cron jobs." CRON "Silent Errors"

Why cron jobs in Symfony don't run silently

Traditional cron jobs in Symfony face several problems:

  • No execution confirmation - You don't know if the job executed successfully
  • Silent failures - Errors are hidden in log files that you rarely check
  • API load limit - External services block requests without notification
  • Resource conflicts - Multiple instances running simultaneously
  • Lack of monitoring - Problems are discovered after the data is no longer current

Let's address these issues step by step.

Basic Symfony Cron Configuration

1. Create Your Console Command

<?php
// src/Command/SyncPaymentsCommand.php

namespace App\Command;

use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use App\Service\StripeService;

#[AsCommand(
    name: 'app:sync:payments',
    description: 'Sync payment data from Stripe API',
)]
class SyncPaymentsCommand extends Command
{
    public function __construct(
        private StripeService $stripeService
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        try {
            $synced = $this->stripeService->syncRecentPayments();
            $output->writeln("Synced {$synced} payments successfully");
            return Command::SUCCESS;
        } catch (\Exception $e) {
            $output->writeln('Sync failed: ' . $e->getMessage());
            return Command::FAILURE;
        }
    }
}

2. Configure Crontab

# Edit crontab
crontab -e

# Sync payments every 15 minutes
*/15 * * * * cd /var/www/html && php bin/console app:sync:payments >> /var/log/cron.log 2>&1

Problem: This setup provides no alerts when API fails, rate limits hit, or data stops syncing.

Advanced Setup with Monitoring

Using CronMonitor for Reliable Execution

Here's how I monitor all critical sync jobs in my SaaS applications:

monitorUrl = $cronMonitorUrl;
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $startTime = microtime(true);

        try {
            // Sync data from external API
            $result = $this->stripeService->syncRecentPayments();

            // Notify success with metrics
            $this->pingMonitor('success', $startTime, [
                'synced' => $result['synced'],
                'errors' => $result['errors']
            ]);

            return Command::SUCCESS;

        } catch (\Exception $e) {
            // Notify failure with error details
            $this->pingMonitor('failure', $startTime, [
                'error' => $e->getMessage(),
                'error_type' => get_class($e)
            ]);

            return Command::FAILURE;
        }
    }

    private function pingMonitor(string $status, float $startTime, array $metadata): void
    {
        $duration = round((microtime(true) - $startTime) * 1000); // ms

        $this->httpClient->request('GET', $this->monitorUrl, [
            'query' => array_merge([
                'status' => $status,
                'duration' => $duration,
            ], $metadata)
        ]);
    }
}

Configure in services.yaml

# config/services.yaml
services:
    App\Command\SyncPaymentsCommand:
        arguments:
            $cronMonitorUrl: '%env(CRONMONITOR_PAYMENTS_URL)%'

Set Environment Variable

# .env
CRONMONITOR_PAYMENTS_URL=https://cronmonitor.app/ping/your-unique-id

Real-World Example: Multi-Source Data Sync

Here's how I sync equipment availability data in Rento from multiple rental partners

<?php
// src/Command/SyncEquipmentAvailabilityCommand.php

namespace App\Command;

use App\Service\PartnerApiService;
use App\Service\EquipmentSyncService;
use Psr\Log\LoggerInterface;

#[AsCommand(name: 'app:sync:equipment')]
class SyncEquipmentAvailabilityCommand extends Command
{
    public function __construct(
        private PartnerApiService $partnerApi,
        private EquipmentSyncService $syncService,
        private LoggerInterface $logger
    ) {
        parent::__construct();
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $startTime = microtime(true);
        $stats = [
            'total' => 0,
            'updated' => 0,
            'errors' => 0,
            'partners' => []
        ];

        try {
            $partners = $this->partnerApi->getActivePartners();

            foreach ($partners as $partner) {
                try {
                    $result = $this->syncPartnerData($partner);
                    $stats['total'] += $result['total'];
                    $stats['updated'] += $result['updated'];
                    $stats['partners'][] = $partner->getName();

                    $output->writeln(
                        "✓ {$partner->getName()}: {$result['updated']}/{$result['total']} updated"
                    );

                } catch (\Exception $e) {
                    $stats['errors']++;
                    $this->logger->error('Partner sync failed', [
                        'partner' => $partner->getName(),
                        'error' => $e->getMessage()
                    ]);

                    $output->writeln(
                        "✗ {$partner->getName()}: {$e->getMessage()}"
                    );
                }
            }

            // Notify monitoring with detailed stats
            $this->notifyMonitor(
                $stats['errors'] === 0 ? 'success' : 'partial',
                $startTime,
                $stats
            );

            return $stats['errors'] === 0 ? Command::SUCCESS : Command::FAILURE;

        } catch (\Exception $e) {
            $this->notifyMonitor('failure', $startTime, [
                'error' => $e->getMessage()
            ]);

            return Command::FAILURE;
        }
    }

    private function syncPartnerData(Partner $partner): array
    {
        // Fetch data from partner API
        $equipment = $this->partnerApi->fetchEquipment($partner);

        // Update local database
        return $this->syncService->updateEquipment($partner, $equipment);
    }
}

Handling Common Issues

1. API Rate Limiting

<?php

namespace App\Service;

use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;

class PartnerApiService
{
    public function __construct(
        private HttpClientInterface $httpClient,
        private RateLimiterFactory $apiLimiter
    ) {}

    public function fetchEquipment(Partner $partner): array
    {
        // Rate limiting (100 requests per hour)
        $limiter = $this->apiLimiter->create($partner->getId());
        $limiter->consume(1)->wait();

        try {
            $response = $this->httpClient->request('GET', 
                $partner->getApiUrl() . '/equipment',
                [
                    'headers' => [
                        'Authorization' => 'Bearer ' . $partner->getApiKey(),
                    ],
                    'timeout' => 30,
                ]
            );

            return $response->toArray();

        } catch (ClientException $e) {
            if ($e->getResponse()->getStatusCode() === 429) {
                throw new RateLimitException('API rate limit exceeded');
            }
            throw $e;
        }
    }
}

2. Lock Mechanism to Prevent Overlaps

use Symfony\Component\Lock\LockFactory;
use Symfony\Component\Lock\Store\RedisStore;

protected function execute(InputInterface $input, OutputInterface $output): int
{
    // Use Redis for distributed locks (multi-server setup)
    $store = new RedisStore($this->redis);
    $factory = new LockFactory($store);
    $lock = $factory->createLock('sync-equipment', 3600);

    if (!$lock->acquire()) {
        $output->writeln('Previous sync still running');

        // Notify monitoring about skip
        $this->pingMonitor('skipped', microtime(true), [
            'reason' => 'locked'
        ]);

        return Command::SUCCESS; // Not a failure
    }

    try {
        $this->performSync();
        return Command::SUCCESS;
    } finally {
        $lock->release();
    }
}

3. Retry Logic for Failed API Calls

<?php

namespace App\Service;

class PartnerApiService
{
    private const MAX_RETRIES = 3;
    private const RETRY_DELAY = 1000; // ms

    public function fetchEquipment(Partner $partner): array
    {
        $attempt = 0;

        while ($attempt < self::MAX_RETRIES) {
            try {
                return $this->makeApiCall($partner);

            } catch (TransportException $e) {
                $attempt++;

                if ($attempt >= self::MAX_RETRIES) {
                    throw new SyncException(
                        "Failed after {$attempt} attempts: " . $e->getMessage()
                    );
                }

                // Exponential backoff
                usleep(self::RETRY_DELAY * (2 ** $attempt) * 1000);
            }
        }
    }
}

Complete Monitoring Solution

Here's my production setup for CronMonitor's own sync jobs:

<?php
// src/Service/CronMonitor.php

namespace App\Service;

use Symfony\Contracts\HttpClient\HttpClientInterface;
use Psr\Log\LoggerInterface;

class CronMonitor
{
    public function __construct(
        private HttpClientInterface $httpClient,
        private LoggerInterface $logger,
        private bool $enabled
    ) {}

    public function start(string $jobName): CronExecution
    {
        return new CronExecution($jobName, $this);
    }

    public function ping(string $url, array $data): void
    {
        if (!$this->enabled) {
            return;
        }

        try {
            $this->httpClient->request('GET', $url, [
                'query' => $data,
                'timeout' => 5,
            ]);
        } catch (\Exception $e) {
            $this->logger->error('CronMonitor ping failed', [
                'url' => $url,
                'error' => $e->getMessage()
            ]);
        }
    }
}

class CronExecution
{
    private float $startTime;
    private array $metadata = [];

    public function __construct(
        private string $jobName,
        private CronMonitor $monitor
    ) {
        $this->startTime = microtime(true);
    }

    public function addMetadata(string $key, mixed $value): self
    {
        $this->metadata[$key] = $value;
        return $this;
    }

    public function success(string $url): void
    {
        $this->monitor->ping($url, array_merge([
            'status' => 'success',
            'duration' => $this->getDuration(),
        ], $this->metadata));
    }

    public function failure(string $url, string $error): void
    {
        $this->monitor->ping($url, array_merge([
            'status' => 'failure',
            'duration' => $this->getDuration(),
            'error' => $error,
        ], $this->metadata));
    }

    private function getDuration(): int
    {
        return (int) round((microtime(true) - $this->startTime) * 1000);
    }
}

Usage in Commands

protected function execute(InputInterface $input, OutputInterface $output): int
{
    $execution = $this->cronMonitor->start('equipment-sync');

    try {
        $result = $this->performSync();

        $execution
            ->addMetadata('synced', $result['synced'])
            ->addMetadata('errors', $result['errors'])
            ->addMetadata('partners', count($result['partners']))
            ->success($_ENV['CRONMONITOR_SYNC_URL']);

        return Command::SUCCESS;

    } catch (\Exception $e) {
        $execution->failure($_ENV['CRONMONITOR_SYNC_URL'], $e->getMessage());
        return Command::FAILURE;
    }
}

Best Practices from Production

After running multiple SaaS applications with hundreds of sync jobs, here are my recommendations:

1. Always Monitor Critical Sync Jobs

# ✅ Good - Monitored sync with timeout
*/15 * * * * timeout 5m php bin/console app:sync:equipment --monitor

# ❌ Bad - Silent sync
*/15 * * * * php bin/console app:sync:equipment

2. Handle Partial Failures Gracefully

// Don't fail entire job if one partner fails
foreach ($partners as $partner) {
    try {
        $this->syncPartner($partner);
        $successCount++;
    } catch (\Exception $e) {
        $this->logger->error("Partner sync failed", [
            'partner' => $partner->getName(),
            'error' => $e->getMessage()
        ]);
        $failureCount++;
    }
}

// Report partial success
if ($failureCount > 0 && $successCount > 0) {
    $this->pingMonitor('partial', $startTime, [
        'success' => $successCount,
        'failed' => $failureCount
    ]);
}

3. Set Appropriate Timeouts

$this->httpClient->request('GET', $apiUrl, [
    'timeout' => 30,        // Socket timeout
    'max_duration' => 120,  // Total request timeout
]);

4. Log API Responses for Debugging

$response = $this->httpClient->request('GET', $apiUrl);

$this->logger->info('API response received', [
    'status' => $response->getStatusCode(),
    'time' => $response->getInfo('total_time'),
    'size' => strlen($response->getContent()),
]);

Debugging Failed Sync Jobs

When sync jobs fail, check these locations:

# System cron logs
tail -f /var/log/syslog | grep CRON

# Symfony logs with context
tail -f var/log/prod.log | grep SyncEquipment

# Check if command works manually
php bin/console app:sync:equipment -vvv

# Test API connectivity
curl -H "Authorization: Bearer YOUR_KEY" https://api.partner.com/equipment

Production Example: Stripe Payment Sync

Here's how I sync payment data for CronMonitor's billing:

cronMonitor->start('stripe-payments');

        try {
            // Get payments from last 24 hours
            $since = new \DateTime('-24 hours');
            $stripePayments = $this->stripeService->fetchPayments($since);

            $stats = [
                'fetched' => count($stripePayments),
                'new' => 0,
                'updated' => 0,
            ];

            foreach ($stripePayments as $payment) {
                $existing = $this->paymentRepository->findByStripeId($payment['id']);

                if ($existing) {
                    $this->updatePayment($existing, $payment);
                    $stats['updated']++;
                } else {
                    $this->createPayment($payment);
                    $stats['new']++;
                }
            }

            $output->writeln(
                "Synced {$stats['fetched']} payments: " .
                "{$stats['new']} new, {$stats['updated']} updated"
            );

            $execution
                ->addMetadata('fetched', $stats['fetched'])
                ->addMetadata('new', $stats['new'])
                ->addMetadata('updated', $stats['updated'])
                ->success($_ENV['CRONMONITOR_STRIPE_URL']);

            return Command::SUCCESS;

        } catch (\Stripe\Exception\ApiErrorException $e) {
            $execution->failure(
                $_ENV['CRONMONITOR_STRIPE_URL'],
                "Stripe API error: {$e->getMessage()}"
            );
            return Command::FAILURE;

        } catch (\Exception $e) {
            $execution->failure(
                $_ENV['CRONMONITOR_STRIPE_URL'],
                $e->getMessage()
            );
            return Command::FAILURE;
        }
    }
}

Monitoring Multiple Environments

Different monitoring URLs for different environments:

# config/services.yaml
parameters:
    cronmonitor.stripe_url: '%env(CRONMONITOR_STRIPE_URL)%'
    cronmonitor.equipment_url: '%env(CRONMONITOR_EQUIPMENT_URL)%'

services:
    App\Command\SyncStripePaymentsCommand:
        arguments:
            $monitorUrl: '%cronmonitor.stripe_url%'

    App\Command\SyncEquipmentAvailabilityCommand:
        arguments:
            $monitorUrl: '%cronmonitor.equipment_url%'
# .env.prod
CRONMONITOR_STRIPE_URL=https://cronmonitor.app/ping/prod-stripe-abc123
CRONMONITOR_EQUIPMENT_URL=https://cronmonitor.app/ping/prod-equipment-xyz789

# .env.staging
CRONMONITOR_STRIPE_URL=https://cronmonitor.app/ping/staging-stripe-abc123
CRONMONITOR_EQUIPMENT_URL=https://cronmonitor.app/ping/staging-equipment-xyz789

Conclusion

Proper cron job setup for API synchronization in Symfony requires:

  1. Robust error handling for external API failures
  2. Rate limiting to respect API quotas
  3. Lock mechanisms to prevent overlapping syncs
  4. Retry logic for transient failures
  5. Monitoring for immediate failure notifications
  6. Detailed logging for debugging and audit trails

After discovering my sync jobs were failing silently for days, I built CronMonitor to solve this problem. It now monitors all critical sync jobs across my SaaS applications, alerting me instantly via Slack, Discord, email, or Telegram when something goes wrong.

Never miss another failed API sync again.

Try CronMonitor free: https://cronmonitor.app/registration

$ share this article
~/newsletter
📬

$ subscribe --cron-tips

Learn best practices for adding cron jobs, practical tips for security, and debugging.

Cron job best practices
Docker integration guides
no spam | unsubscribe anytime
>

# Join 500+ developers monitoring their cron jobs