azure-oss/storage-blob-laravel
Azure Blob Storage filesystem driver for Laravel. Register an Azure container as a disk and use Laravel's Storage facade to upload, download, list, delete files, and generate URLs with familiar APIs—no custom SDK code required.
Install and wire it up
composer require azure-oss/storage-blob-laravel
The service provider is auto-discovered. If you're on an older Laravel or disabled discovery, register AzureOss\StorageBlobLaravel\AzureStorageBlobServiceProvider manually in config/app.php.
Add a disk in config/filesystems.php:
'disks' => [
'azure' => [
'driver' => 'azure-blob',
'connection_string' => env('AZURE_STORAGE_CONNECTION_STRING'),
'container' => env('AZURE_STORAGE_CONTAINER'),
],
],
AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=...;EndpointSuffix=core.windows.net"
AZURE_STORAGE_CONTAINER=uploads
Some versions also accept account_name / account_key / endpoint_suffix instead of a connection string — check vendor/azure-oss/storage-blob-laravel/src/ before you copy config from a blog post, since this is a small package and the key names have shifted between releases.
Where to look first
config/filesystems.php — the only place most people ever edit.vendor/azure-oss/storage-blob-laravel/src/ — one service provider plus a filesystem adapter. Read the adapter class end-to-end; it's small, and it tells you exactly which FilesystemAdapter methods are implemented versus inherited as no-ops.microsoft/azure-storage-blob) — this package is a thin translation layer over BlobRestProxy / BlobClient. When something behaves oddly, the answer is almost always in the SDK docs, not the Laravel wrapper.First use case: offloading user uploads
The lowest-friction win is moving generated files and uploads off the app server:
use Illuminate\Support\Facades\Storage;
Storage::disk('azure')->put('exports/2026-09/report.csv', $csv);
$url = Storage::disk('azure')->url('exports/2026-09/report.csv');
Pre-create the container before your first request — either in the portal, or:
az storage container create --name uploads --account-name myaccount
Then verify with Storage::disk('azure')->exists('exports/2026-09/report.csv') in php artisan tinker. If that round-trips, the rest is standard Laravel filesystem work.
Drive everything off the disk name, not a hardcoded adapter
The main value of this package is that it is not special. Keep code written against the Storage facade and select the driver in config:
// config/filesystems.php
'assets' => [
'driver' => env('ASSETS_DRIVER', 'local'),
// ...
],
This lets you run local in dev/CI and azure-blob in production, and it makes Storage::fake('assets') in tests a drop-in swap.
Request uploads
$path = $request->file('avatar')->store('avatars/'.auth()->id(), 'azure');
// or with a controlled name
$request->file('avatar')->storeAs('avatars/'.auth()->id(), $filename, 'azure');
store() returns the object key. Persist the key, not the URL — you can always regenerate a URL, but you can't recover a key from a private-container URL cleanly.
Streaming instead of buffering
$handle = fopen($localPath, 'r');
Storage::disk('azure')->writeStream('imports/big.csv', $handle);
fclose($handle);
For anything over a few hundred MB, prefer streams or the SDK's block-blob upload path over put() with a string. A single-shot put() of a multi-GB file is where timeouts and memory exhaustion show up.
Reads, copies, moves
$contents = Storage::disk('azure')->get('imports/big.csv');
Storage::disk('azure')->copy('a/report.pdf', 'archive/report.pdf');
Storage::disk('azure')->move('inbox/x.csv', 'processed/x.csv');
copy and move map to Azure server-side copy, which is asynchronous at the service level. Confirm in the adapter whether it polls or returns immediately; if it returns immediately, don't chain a read of the destination in the same request/job.
Directory conventions
Azure Blob has a flat namespace. The adapter synthesizes folders from / in the key and by writing zero-byte marker blobs for makeDirectory. Adopt a key convention once and enforce it:
$prefix = "tenants/{$tenantId}/documents/{$documentId}";
Storage::disk('azure')->put("{$prefix}/original.pdf", $bytes);
Per-tenant or per-environment containers
$disk = Storage::build([
'driver' => 'azure-blob',
'connection_string' => config('services.azure.connection_string'),
'container' => "tenant-{$tenant->id}",
]);
$disk->put('logo.png', $bytes);
Storage::build() gives you dynamic disks without touching config. Cache the built disk if you create it per request in a hot path.
Presigned URLs for private containers
$url = Storage::disk('azure')->temporaryUrl(
'reports/q3.pdf',
now()->addMinutes(15),
);
If the adapter version you're on doesn't implement temporaryUrl, generate a SAS directly with the SDK's BlobSharedAccessSignatureHelper from your account key, or route through a controller that streams via response()->streamDownload(fn () => readfile(...)).
Queue jobs and cleanup
class ProcessUpload implements ShouldQueue
{
public function handle(): void
{
$disk = Storage::disk('azure');
$disk->put('processing/'.$this->id, $payload);
try {
// ...
} finally {
$disk->delete('processing/'.$this->id);
}
}
}
Wrap writes that matter in retry(3, fn () => $disk->put(...), 200) — the underlying SDK doesn't give you Laravel-style retry semantics, and transient 5xx/timeouts do happen.
Testing
Storage::fake('azure') works because Laravel swaps in a local adapter regardless of the configured driver. Use it for 99% of tests, and keep one opt-in integration test against Azurite:
docker run -d -p 10000:10000 mcr.microsoft.com/azure-storage/azurite \
azurite-blob --blobHost 0.0.0.0
AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;"
Fakes do not exercise container ACLs, async copy, or SAS signing — those are exactly the things that break in production.
The driver string is azure-blob, not azure
'driver' => 'azure-blob' must match the key the service provider registers. A typo produces a confusing "Driver [azure] is not supported" or a silent fallback to a local disk. Grep the service provider for the exact string.
Containers are not auto-created
Unlike S3-backed Laravel setups where the bucket often exists already, a missing container throws ServiceException with ContainerNotFound. Either provision containers in your deploy script/IaC, or catch it at boot:
try {
Storage::disk('azure')->put('.probe', '');
Storage::disk('azure')->delete('.probe');
} catch (\MicrosoftAzure\Storage\Common\Exceptions\ServiceException $e) {
if ($e->getErrorCode() === 'ContainerNotFound') {
// create via SDK or fail loudly
}
}
url() on a private container gives you a 403-generating string
Azure has no per-blob ACL. Public access is a container-level setting. So:
url() is only correct for containers configured for public/blob access.setVisibility($path, 'public') cannot make a single blob public — if the adapter implements it, it's changing container ACL or silently doing nothing. Don't design around per-file visibility.temporaryUrl() and set a short TTL.files() / directories() are the weak spot
Implementations typically use delimiter-based listing. Watch for:
continuationToken, allFiles() silently truncates on large containers. Confirm in the adapter source before relying on it for a full export.directories() will miss prefixes that contain only deeper keys, depending on implementation.deleteDirectory() may delete only marker blobs and leave the actual objects. Test this explicitly with a nested prefix.Paths with spaces and special characters
Blob names are URL-encoded on the wire. url() and temporaryUrl() can disagree about encoding for keys containing spaces, #, +, or non-ASCII. Stick to [a-z0-9/._-] in generated keys and you'll never think about this.
Case sensitivity bites late
Blob names and container names are case-sensitive. A macOS/Windows dev machine and a case-insensitive test setup will hide bugs that appear on Linux and in Azure. Add an assertion on stored keys in tests rather than trusting the round-trip.
Errors are SDK errors, not Laravel errors
You'll catch MicrosoftAzure\Storage\Common\Exceptions\ServiceException — inspect getErrorCode() (BlobNotFound, AuthenticationFailed, ContainerNotFound, AuthorizationPermissionMismatch) and getErrorText(). Laravel's UnableToReadFile / UnableToWriteFile may or may not be wrapped around it depending on the adapter version; catch both in job code.
Listing metadata is N+1
size(), lastModified(), and mimeType() each cost a getBlobProperties round trip. If you render a file browser with 50 rows × 3 calls, you've added 150 requests of latency. Either fetch properties once via a single listing call with metadata included (SDK listBlobs with include=metadata), or cache the results.
No connection pooling / retry middleware from the SDK
microsoft/azure-storage-blob predates the modern Azure SDK and has no built-in retry policy. Add your own for idempotent operations, and if the disk config forwards Guzzle options, set sane connect_timeout and timeout values. Uploads that take longer than your default PHP max_execution_time belong in a queue.
Configuration and secrets
php artisan config:cache freezes env values; re-run it after changing AZURE_STORAGE_*.config/filesystems.php literals and out of version control; env() in config is fine.// AppServiceProvider::boot()
Storage::extend('azure-blob', function ($app, $config) {
// build your own adapter with a SAS-bearing BlobClient, then wrap it
return new YourAzureAdapter(...);
});
Version risk is real
67 stars, zero dependents, single-maintainer project. Pin it in composer.json ("azure-oss/storage-blob-laravel": "^1.0") and keep your own integration test against Azurite so a composer update or an upstream SDK deprecation doesn't take you by surprise. If the package stalls, the escape hatch is straightforward: it's a thin adapter, and porting your usage to League\Flysystem\AzureBlobStorage or a hand-rolled FilesystemAdapter over the newer Azure\Storage\Blob\BlobServiceClient is a day's work, not a rewrite.
Extension points worth knowing
Storage::extend('azure-blob', ...) — wrap the adapter for logging, metrics, key prefixing, or swapping in a newer SDK.Storage::build([...]) — dynamic per-tenant disks without config edits.getDriver() / client accessor (if present) — reach for the raw BlobRestProxy/BlobClient when you need features the Laravel contract doesn't expose: container ACLs, blob tiers (Hot/Cool/Archive), lifecycle rules, or listBlobs with metadata.How can I help you explore Laravel packages today?