Chapter 26: Service Runner#
1. Work That Runs Outside HTTP Requests#
Not everything fits in an HTTP request. A queue consumer that processes emails. A cache warmer that pre-populates product data on a schedule. These run continuously in the background, independent of web traffic.
Tina4's service runner provides a pattern for long-running background workers. They start with the application, restart on failure, and shut down cleanly.
2. The Service Interface#
A background service implements a simple contract: a run() method that loops indefinitely, and a stop() method that signals it to exit.
<?phpuse Tina4\Service;โclass EmailQueueWorker extends Service{ private bool $running = true; private \Tina4\Queue $queue;โ public function __construct() { $this->queue = new \Tina4\Queue(topic: 'emails'); }โ public function run(): void { echo "[EmailQueueWorker] Starting...\n";โ while ($this->running) { $job = $this->queue->pop();โ if ($job === null) { // No jobs pending -- sleep and check again sleep(1); continue; }โ try { $this->processEmail($job->payload); $job->complete(); echo "[EmailQueueWorker] Email sent to {$job->payload['to']}\n"; } catch (\Throwable $e) { $job->fail($e->getMessage()); echo "[EmailQueueWorker] Failed: {$e->getMessage()}\n"; } }โ echo "[EmailQueueWorker] Stopped.\n"; }โ public function stop(): void { $this->running = false; }โ private function processEmail(array $payload): void { // Actual email sending logic here // \Tina4\Messenger::send($payload['to'], $payload['subject'], $payload['body']); echo " Sending: {$payload['subject']} -> {$payload['to']}\n"; }}3. Registering and Starting Services#
<?phpuse Tina4\ServiceRunner;โ// Register servicesServiceRunner::registerService('email_queue_worker', new EmailQueueWorker());ServiceRunner::registerService('report_generator', new ReportGenerator());ServiceRunner::registerService('cache_warmer', new CacheWarmer());โ// Start all servicesServiceRunner::start();start() launches each service in its own process or thread (depending on the platform). Services run concurrently and independently.
4. A Report Generator Service#
<?phpuse Tina4\Service;โclass ReportGenerator extends Service{ private bool $running = true;โ public function run(): void { echo "[ReportGenerator] Starting...\n";โ while ($this->running) { $now = new \DateTimeImmutable(); $hour = (int) $now->format('G');โ // Generate daily summary at 02:00 if ($hour === 2 && (int) $now->format('i') === 0) { $this->generateDailySummary($now->format('Y-m-d')); sleep(61); // Prevent double-trigger within the same minute continue; }โ sleep(30); // Check every 30 seconds }โ echo "[ReportGenerator] Stopped.\n"; }โ public function stop(): void { $this->running = false; }โ private function generateDailySummary(string $date): void { echo "[ReportGenerator] Generating summary for {$date}...\n"; // Build and email/store the report }}5. A Cache Warmer Service#
Pre-populate the cache at startup and refresh it before it expires:
<?phpuse Tina4\Service;use function Tina4\cache_set;โclass CacheWarmer extends Service{ private bool $running = true; private int $ttl = 300; // 5 minutes private int $refreshBefore = 60; // Refresh 60 seconds before expiryโ public function run(): void { echo "[CacheWarmer] Starting...\n";โ // Warm on start $this->warmAll();โ while ($this->running) { // Refresh $refreshBefore seconds before expiry sleep($this->ttl - $this->refreshBefore); $this->warmAll(); }โ echo "[CacheWarmer] Stopped.\n"; }โ public function stop(): void { $this->running = false; }โ private function warmAll(): void { echo "[CacheWarmer] Warming cache...\n";โ $db = new \Tina4\Database(getenv('TINA4_DATABASE_URL'));โ $categories = $db->fetchAll("SELECT * FROM categories ORDER BY name"); cache_set('categories:all', $categories, $this->ttl);โ $featured = $db->fetchAll( "SELECT * FROM products WHERE featured = 1 ORDER BY rank LIMIT 20" ); cache_set('products:featured', $featured, $this->ttl);โ echo "[CacheWarmer] Cache warmed (" . count($categories) . " categories, " . count($featured) . " featured products)\n"; }}6. Graceful Shutdown#
Signal handling lets your service exit cleanly when the server receives SIGTERM (e.g., during a deployment):
<?phpuse Tina4\Service;โclass RobustWorker extends Service{ private bool $running = true;โ public function run(): void { // Register signal handlers if (function_exists('pcntl_signal')) { pcntl_signal(SIGTERM, function () { echo "[RobustWorker] SIGTERM received, stopping...\n"; $this->stop(); });โ pcntl_signal(SIGINT, function () { echo "[RobustWorker] SIGINT received, stopping...\n"; $this->stop(); }); }โ echo "[RobustWorker] Running...\n";โ while ($this->running) { if (function_exists('pcntl_signal_dispatch')) { pcntl_signal_dispatch(); }โ $this->doWork(); sleep(5); }โ echo "[RobustWorker] Graceful shutdown complete.\n"; }โ public function stop(): void { $this->running = false; }โ private function doWork(): void { // Business logic here }}7. Running Services as a Standalone Script#
For simple deployments, run services directly as a PHP CLI script:
src/workers/run.php:
<?phprequire __DIR__ . '/../../vendor/autoload.php';โuse Tina4\ServiceRunner;โServiceRunner::registerService('email_queue_worker', new EmailQueueWorker());ServiceRunner::registerService('report_generator', new ReportGenerator());ServiceRunner::start();Run it:
php src/workers/run.phpKeep it alive with a process manager. With Supervisor:
[program:tina4-workers]command=php /var/www/app/src/workers/run.phpdirectory=/var/www/appautostart=trueautorestart=truestderr_logfile=/var/log/tina4-workers.err.logstdout_logfile=/var/log/tina4-workers.out.loguser=www-dataWith systemd:
[Unit]Description=Tina4 Background WorkersAfter=network.targetโ[Service]Type=simpleUser=www-dataWorkingDirectory=/var/www/appExecStart=/usr/bin/php src/workers/run.phpRestart=alwaysRestartSec=5โ[Install]WantedBy=multi-user.target8. Running with Docker#
FROM php:8.3-cliโWORKDIR /appCOPY . .RUN composer install --no-devโCMD ["php", "src/workers/run.php"]# docker-compose.yml (excerpt)services: web: build: . # `tina4 serve` requires the Rust CLI; inside Docker we run the framework # directly with TINA4_OVERRIDE_CLIENT=true so the framework boots without it. command: sh -c "TINA4_OVERRIDE_CLIENT=true php index.php"โ workers: build: . command: php src/workers/run.php environment: - TINA4_DATABASE_URL=sqlite:./data/app.db - TINA4_MAIL_HOST=mailhog depends_on: - dbThe web service and workers service share the same image. They run independently.
9. Gotchas#
1. Services blocking each other#
Problem: Two services are registered but only the first one runs. The second never starts.
Cause: The first service's run() method blocks the main thread forever. The second service never gets control.
Fix: Either use ServiceRunner with proper multi-process support (each service in its own process), or run each service in a separate PHP process. Do not put multiple blocking loops in the same process without concurrency support.
2. Memory leak in long-running workers#
Problem: The worker process grows in memory until the server runs out.
Cause: Variables accumulate in the loop. Large result sets are never freed.
Fix: unset() large variables inside the loop. Call gc_collect_cycles() periodically. Keep loop iterations small, process one job at a time, not thousands.
3. Database connection dropped#
Problem: The worker fails after several hours with "server has gone away" or "connection lost."
Cause: The database server closes idle connections. The worker holds a connection for hours and then tries to use a dead connection.
Fix: Reconnect on failure. Wrap your database calls in a try/catch. On PDOException matching "gone away" or "connection lost," create a new connection and retry.
4. Not logging worker errors#
Problem: The worker silently fails. Nothing appears in logs. Jobs pile up.
Cause: Exceptions inside processEmail() or doWork() are caught by a bare catch block that does nothing.
Fix: Always log exceptions inside the worker loop using Debug::message(). At minimum, log the error and continue to the next job.