leezy/pheanstalk-bundle
LeezyPheanstalkBundle integrates the Pheanstalk beanstalkd client with Symfony, offering configurable connections, DI services, console commands, and profiler support to simplify setting up, managing, and monitoring background job queues in Symfony applications.
Read this first: the package is a Symfony bundle, not a Laravel package. It wires pda/pheanstalk into Symfony's DI container. If the target app is Laravel, stop here and use the framework's native beanstalkd driver (QUEUE_CONNECTION=beanstalkd, composer require pda/pheanstalk) — you get dispatch(), Queue::push, failed-job handling, and tube config for free, and this bundle adds nothing. Everything below assumes a Symfony app, or a polyglot estate where one Symfony service owns producers/consumers and you're writing against the same transport contract.
Minimal setup (Symfony):
composer require leezy/pheanstalk-bundle
// config/bundles.php
Leezy\PheanstalkBundle\LeezyPheanstalkBundle::class => ['all' => true],
# config/packages/leezy_pheanstalk.yaml
leezy_pheanstalk:
connections:
default:
host: '%env(BEANSTALKD_HOST)%'
port: 11300
timeout: 60
Run beanstalkd locally (docker run -p 11300:11300 schickling/beanstalkd) and confirm the service is registered before writing any code:
bin/console debug:container leezy
bin/console debug:container --parameters | grep -i beanstalk
That command is the single most useful thing to learn. Service IDs and config keys have shifted across versions; trust the container dump, not the README.
First use case: push one email job and consume it in a console command. That's the whole loop — put() on the request path, reserve() in a long-running worker. Everything else (priority, delay, bury) is tuning on top of those two calls.
Files to read in vendor/leezy/pheanstalk-bundle/: DependencyInjection/Configuration.php (the real config schema — it's authoritative over the docs), Resources/config/services.xml (service IDs and aliases), and Command/ (the bundled produce/consume commands, useful as reference implementations).
Producer — inject the connection, never new Pheanstalk(). The whole value of the bundle is that connection config lives in YAML and the client is a shared service. Inject Pheanstalk\PheanstalkInterface (or the concrete class if the alias isn't present in your version) and push:
public function __construct(private PheanstalkInterface $pheanstalk) {}
public function dispatch(SendWelcomeEmail $job): void
{
$this->pheanstalk
->useTube('mail')
->put(
json_encode(['type' => 'welcome_email', 'v' => 1, 'payload' => $job->toArray()]),
self::PRIORITY_NORMAL, // lower number = more urgent
0, // delay seconds
120 // TTR seconds
);
}
Define an explicit payload envelope and version it. Beanstalkd stores opaque bytes — the bundle does no serialization for you. A {type, v, payload, idempotency_key, attempts} shape lets you evolve job formats and lets consumers reject unknown types instead of fatally erroring. This is also the contract that makes a polyglot tube (Symfony producer, Laravel consumer) possible at all — Laravel wraps jobs in its own JSON envelope, so do not share a tube between Laravel and this bundle unless you deliberately re-implement the envelope on one side. Separate tubes per producer stack is the cheap answer.
Consumer — a supervised console command with reserveWithTimeout. Never call bare reserve(); it blocks indefinitely and makes graceful shutdown impossible.
while ($this->running) {
$job = $this->pheanstalk->reserveWithTimeout(5);
if ($job === null) { continue; } // timeout, loop to check shutdown flag
try {
($this->handlers[$job->getData()->type])($job->getData()->payload);
$this->pheanstalk->delete($job);
} catch (RetryableException $e) {
$this->pheanstalk->release($job, self::PRIORITY_NORMAL, $this->backoff($job));
} catch (Throwable $e) {
$this->pheanstalk->bury($job, self::PRIORITY_LOW); // park for inspection
$this->logger->error('job buried', ['data' => $job->getData(), 'ex' => $e]);
}
}
Supervise with systemd or supervisor, handle SIGTERM by setting $this->running = false so the worker finishes the in-flight job before exiting.
Multiple connections when producer and consumer have different reliability needs. Define a second connection in YAML for a separate cluster (e.g. priority and bulk), and inject the named service. Same pattern for tube-per-domain — keep a constants class so 'mail' isn't a magic string scattered across ten files.
Priority bands, not raw integers. Reserve ranges: 0–99 for user-blocking work (password resets), 1000 default, 4000+ for batch/backfill. Beanstalkd priority is 0–2³¹ with lower = more urgent; without conventions you get accidental starvation.
Idempotency is mandatory, not optional. Beanstalkd redelivers whenever TTR expires, so a slow job gets executed twice concurrently. Stamp an idempotency key in the payload and make handlers upsert-or-skip (unique constraint, Cache::add(), whatever fits).
Testing: swap the injected connection for a double in unit tests (mock put/delete), and use a real beanstalkd container in CI integration tests. The bundle's config-driven service makes the swap trivial — bind your fake in config/services_test.yaml.
Consider Symfony Messenger instead if you're greenfield on modern Symfony: symfony/beanstalkd-messenger gives you the framework-native transport, retry strategy, and failure transport. This bundle is the right call when you want direct control over Pheanstalk primitives (bury, touch, statsTube) that Messenger abstracts away.
Durability is off by default. Beanstalkd is in-memory; a broker restart silently discards every queued job. If any job matters, run with -b /var/lib/beanstalkd/binlog and size the disk for retention. The bundle does nothing to change this, and its presence can create a false sense of resilience — call it out in code review.
TTR is the #1 source of duplicate work. Default TTR is 60s. A job that runs 90s is re-reserved and executed again while the first copy is still going. Set TTR per job class to ~2–3× the p99 runtime, and call touch($job) periodically for genuinely long jobs. Watch reserves on stats-job — a climbing count with no errors means you're re-delivering, not retrying.
Job size cap is 65,535 bytes. Serialized PDFs, base64 blobs, and large Eloquent models blow past this and throw JobTooBigException. Push an ID and rehydrate in the worker. Raise with -z only if you've actually measured.
Release with delay, not with bury, for retries. release($job, $priority, $delay) is your backoff knob — exponential delay on attempt N. bury() is for terminal failures that a human should inspect; it's your dead-letter queue. Read stats-job's reserves count back as the attempt number (remember it resets to 0 on put), or track attempts in the payload — payload is safer.
Tube names: ≤200 bytes, no whitespace/control chars, and they don't auto-clean. Orphaned tubes linger forever and clutter list-tubes. Prefix with the app/environment (prod.mail) and write a cleanup task.
Long-running workers hold dead connections. After a broker restart or network blip, the injected Pheanstalk instance throws ConnectionException on the next call and does not auto-reconnect. Wrap reserveWithTimeout in a catch that tears down and reconnects with backoff — otherwise your supervisor happily restarts a worker that dies every five seconds.
Service IDs vary by version. leezy.pheanstalk vs named connections (leezy.pheanstalk.<name>) vs interface aliases — confirm with debug:container before wiring constructor args, and prefer interface type-hints that the bundle aliases. If autowiring fails, that's why.
Config key quirks. timeout in the connection block is the connect/read timeout, not TTR; the two get confused constantly. Env-var references inside connections: need %env()% syntax and will fail silently-ish at compile time if quoted wrong.
Watch the maintenance signal. ~124 stars, zero dependents, single maintainer. Pin the version in composer.json, verify the constraint band against your Symfony LTS and PHP runtime before scoping work, and assume you may vendor the bundle — the glue is small enough to fork if it stalls, but budget for that as real recurring cost.
Observability belongs before the pilot. Export stats() and statsTube() to your metrics system and alert on current-jobs-ready age (backlog), current-jobs-buried (failures), and current-connections. The bundle gives you no dashboarding; two dozen lines in a cron command gets you there. Note that Horizon is Redis-only — beanstalkd monitoring is DIY.
How can I help you explore Laravel packages today?