azure-oss/storage-queue
A Laravel/PHP package for integrating Azure Storage Queues, providing a queue driver that lets your application push and process jobs through Microsoft Azure's cloud queue service using the Azure SDK.
What this is (and isn't). azure-oss/storage-queue is a read-only subtree split of the AzureOss\Storage\Queue component from php-oss-for-azure/azure-storage-queue-php. It is a thin, typed SDK over the Azure Storage Queue REST API — not a Laravel package. There is no service provider, no facade, no config/queue.php driver. You wire it in yourself, which is either the appeal (no framework coupling, works in a plain PHP worker or a Lambda) or the tax.
Install and first look.
composer require azure-oss/storage-queue
Before writing a line of code, read the monorepo README and src/ — the subtree split repo is generated, so issues and PRs belong upstream, and the split's own README may lag. Confirm the exact class names in the version you install (QueueServiceClient, QueueClient, and the message model are the ones you'll spend your time in).
Connect. Two paths in practice:
use AzureOss\Storage\Queue\QueueServiceClient;
// Local dev against Azurite
$service = QueueServiceClient::fromConnectionString('UseDevelopmentStorage=true');
// Cloud
$service = QueueServiceClient::fromConnectionString(
'DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net'
);
If you're avoiding account keys, the sibling identity package gives you a TokenCredential and you construct the client from an endpoint URL plus credential instead. Keys in .env are the pragmatic default for a Laravel app; managed identity is the answer once you're on App Service or AKS.
First use case. The one that pays for itself immediately: an async outbox. Anything slow and non-blocking in a request — sending a welcome email, calling a third-party API, generating a PDF — gets pushed as a JSON message and processed by a worker.
$queue = $service->getQueueClient('emails');
// $queue->createQueue(); // once, provisioned in IaC ideally
$queue->sendMessage(json_encode([
'type' => 'welcome',
'user_id' => $user->id,
]));
Then a consumer loop (receiveMessages → handle → deleteMessage). Get that working end-to-end against Azurite before you attempt to build a Laravel queue driver on top of it.
Build a real Laravel queue driver, don't hand-roll per-job.
The leverage move is one Illuminate\Contracts\Queue\Queue implementation plus a connector registered in QueueServiceProvider. Map the contract onto Azure's primitives carefully:
| Laravel | Azure Storage Queue |
|---|---|
push |
sendMessage (payload JSON-encoded, consistent with Laravel's own payload format) |
pushRaw with delay |
visibilitytimeout on sendMessage |
pop |
receiveMessages(max: 1, visibilityTimeout: $retryAfter) |
release |
updateMessage with new visibility timeout |
deleteReserved |
deleteMessage($messageId, $popReceipt) |
size |
approximate message count from queue properties |
clear |
clearMessages |
Critical detail: Azure hands you a popReceipt on every receive, and delete/update must present the current one. Store it on the reserved job alongside the message ID; if anything updates the message, the receipt rotates and your stored one goes stale. This is the single most common source of silent "job never deletes" bugs.
Set retry_after deliberately. Azure's default visibility timeout is 30 seconds. If your job takes four minutes, the message reappears and a second worker picks it up while the first is still running. Set retry_after above your p99 job duration — not your average. And remember retry_after and visibilityTimeout are the same knob, so a long retry_after also means a crashed worker holds a message hostage for that long.
Delays are free and cheap. Because delayed visibility is native, dispatch(...)->delay(now()->addMinutes(10)) works without a scheduler table. Use it for retry backoff, drip campaigns, and rate-limit spacing instead of a jobs table with a available_at column.
Idempotency is not optional. Azure Storage Queues are at-least-once with no deduplication. Every consumer must be safe to run twice. Practical pattern: a processed_messages table keyed on the message ID with a unique index, insert-first, bail on conflict.
Implement your own dead-lettering. Unlike Service Bus, Storage Queues have no built-in DLQ. The dequeueCount on each received message increments per receive — that's your signal. In the worker: if dequeueCount >= maxTries, serialize the message into a poison queue or a failed_jobs table and delete from the original. Wire this to Laravel's failed() hook so queue:failed and queue:retry still behave.
Batching for throughput. receiveMessages accepts a batch (up to 32). Process in a loop, but note there is no batch send — enqueueing 1,000 messages is 1,000 HTTP calls. For fan-out, either accept the latency, run sends through Http::pool-style concurrency, or use a single "batch id" message that the consumer expands server-side.
Interop is a headline feature. Because this is a plain REST client, a Laravel producer and an Azure Function / .NET consumer on the same queue is a first-class setup. Agree on a JSON envelope with a version field and keep the payload free of PHP-serialized objects. Avoid serialize() in messages you might read from another runtime.
Where it beats the alternative. If you're already on Azure and paying for a Redis instance purely as a queue, this removes a dependency and a bill. It's also genuinely durable — messages survive restarts, unlike a sync or database-with-truncation shortcut.
Naming and provisioning rules will bite you. Queue names must be 3–63 characters, lowercase letters/numbers/hyphens only, and start with a letter. emails_prod and EmailsProd both fail. Validate queue names at config-load time, not at 2 a.m. in a worker.
64 KB message ceiling — and base64 inflation. Azure enforces 64 KB after encoding. Many Azure SDKs base64-encode payloads by default, which inflates by ~33%, so your effective limit is nearer 47 KB. Check whether your version exposes a message-encoding option and whether the default matches your other consumers. Anything approaching the limit should be a pointer (a blob path or model ID), not the payload.
UseDevelopmentStorage=true needs Azurite actually running. The connection string is not a mock — it's a real HTTP call to 127.0.0.1:10001. Wire Azurite into your test bootstrap or CI, and be aware that Azurite is a faithful-but-not-perfect emulator; it has historically been looser on some validation than the real service.
Peek vs. receive. peekMessages doesn't increment dequeueCount and doesn't need a popReceipt — use it for dashboards, health checks, and "is the queue backed up?" alerting. Never build your consumer on peek; it can't delete.
Time-to-live defaults to infinite, visibility caps at 7 days. A message with no TTL lives forever, so a misconfigured producer can accumulate a queue you can't drain fast enough. Set an explicit TTL on every send. Conversely, visibility timeouts above 7 days are rejected — don't try to schedule "next quarter" via a queue.
Throttling is real and shows up as 5xx. Under a hot loop you'll hit per-account request-rate limits. Retry with exponential backoff plus jitter, and never retry a sendMessage blindly without idempotency on the consumer side — you'll duplicate. The SDK may or may not retry for you; verify and configure it explicitly.
Stale popReceipt after update. Any updateMessage call returns a new receipt. If you use release() and then try to delete with the old receipt, you get a 400. Re-fetch, or track the latest receipt on your reserved-job wrapper.
Maturity check before you commit. This is a zero-star, zero-dependent subtree split with a small maintenance surface. Pin the version in composer.json, read the upstream changelog on upgrades, and keep your queue driver behind your own interface so swapping to SQS or Redis is a config change, not a rewrite. The abstraction cost is an afternoon; the lock-in cost is unbounded.
Extension points are yours to build. The package deliberately stops at the REST layer. That means: your retry policy, your DLQ, your metrics (emit queue depth and dequeueCount distribution to your observability stack — depth alone won't tell you a poison message is looping), your worker supervision, and your graceful-shutdown handling (finish the in-flight message, then stop receiving). None of that ships in the box, and all of it is what separates a working prototype from a queue you trust in production.
How can I help you explore Laravel packages today?