integrated/storage-bundle
A Symfony storage bundle that provides a simple abstraction for managing files, uploads, and storage paths across local and remote adapters, with configurable services and minimal setup for existing PHP applications.
This is a Bitbucket-hosted Symfony bundle with zero public adoption. Before writing a line of application code, verify three things in this order: (1) you actually have read access to the repo, (2) which framework it targets (-bundle naming and config/bundles.php registration signal Symfony, not Laravel package discovery), and (3) whether it wraps Flysystem or ships its own driver interface. That last question determines everything below — if it wraps Flysystem 1.x/2.x, a Laravel 10/11 app on Flysystem 3 will not solve cleanly.
1. Mirror and pin. Do not let CI depend on Bitbucket uptime or a mutable dev-master:
git clone --mirror https://bitbucket.org/eactive/integrated-storage-bundle.git
# push to your internal Git host, then:
git rev-parse HEAD # pin this SHA in composer.lock and in the require constraint
{
"repositories": [
{ "type": "vcs", "url": "[email protected]:mirrors/integrated-storage-bundle.git" }
],
"require": {
"integrated/storage-bundle": "dev-master#<sha>"
}
}
2. Where to look first, in this order — don't trust a README to exist:
src/DependencyInjection/Configuration.php — the authoritative config tree. This is your real documentation.src/DependencyInjection/*Extension.php — shows which services get registered and with which tags.composer.json — check require for symfony/*, illuminate/*, and league/flysystem version constraints. This is your go/no-go gate.3. Register it in Laravel. Laravel has no config/bundles.php. You have two realistic bridges:
Bridge A — expose the bundle as a Laravel disk driver. This is the highest-leverage path: app code keeps using Storage::disk('media'), and the package is an implementation detail.
// app/Providers/IntegratedStorageServiceProvider.php
use Illuminate\Support\Facades\Storage;
use League\Flysystem\Filesystem;
public function boot(): void
{
Storage::extend('integrated', function ($app, array $config) {
// Confirm the real FQCN + constructor from src/
return new Filesystem(new \Integrated\Bundle\StorageBundle\Adapter\IntegratedAdapter($config));
});
}
If the bundle's adapter does not implement League\Flysystem\FilesystemAdapter, write a thin wrapper that translates readStream/writeStream/delete/exists onto its driver interface. That wrapper is 50–100 lines and is the only file you'll maintain.
Bridge B — instantiate the adapter directly. Skip the Symfony DI extension entirely; construct the adapter in a singleton binding and inject it. You lose the bundle's config validation but avoid a container mismatch. Prefer A.
4. First use case — the smoke test. One non-critical disk, one bucket, three operations, run in php artisan tinker:
$d = Storage::disk('media');
$d->put('healthcheck/ping.txt', 'ok');
$d->exists('healthcheck/ping.txt'); // true
$d->get('healthcheck/ping.txt'); // "ok"
$d->temporaryUrl('healthcheck/ping.txt', now()->addMinutes(5));
$d->delete('healthcheck/ping.txt');
Also test failure behaviour deliberately: revoke the credentials and confirm you get an exception, not a silent write to local. Set an explicit exit criterion — if you cannot get this green in two days, fall back to a first-party Flysystem driver.
Hide it behind a gateway interface. Never let controllers, jobs, or models call the bundle directly. Define a domain contract and one adapter that delegates:
interface StorageGateway
{
public function put(string $key, $stream): void;
public function get(string $key): string;
public function url(string $key, int $ttlSeconds): string;
public function delete(string $key): void;
}
final class IntegratedStorageGateway implements StorageGateway { /* delegates to disk */ }
Bind it in a service provider and, importantly, ship a FakeStorageGateway for tests. The swap-out cost if the package disappoints is then one binding, not a repo-wide grep.
Keys are a convention, not an afterthought. The package gives you an API, not a naming policy. Build keys centrally:
function media_key(int $tenantId, string $kind, string $filename): string
{
return sprintf('tenants/%d/%s/%s/%s',
$tenantId, $kind, now()->format('Y/m'), Str::uuid()->toString() . '-' . basename($filename));
}
Never interpolate user-supplied paths. Sanitise the filename to [a-zA-Z0-9._-], keep the UUID in front so listings sort predictably, and never include the original extension in a way that decides content type — set Content-Type explicitly from a server-side allowlist.
Stream, don't buffer. The bundle advertises streamed reads/writes; use them for anything user-uploaded:
$stream = fopen($request->file('upload')->getRealPath(), 'rb');
Storage::disk('media')->writeStream($key, $stream);
if (is_resource($stream)) { fclose($stream); }
For downloads, return Storage::disk('media')->readStream($key) from a StreamedResponse rather than ->get() into a variable. For async work, pass the temporary path or an object key through the job — never a hydrated UploadedFile or a PHP resource, which won't serialise.
Signed URLs — issue on read, expire short. If the bundle generates signed URLs, keep TTLs in config, not in controllers, and cache the generated URL per key for less than its TTL so you're not signing on every request:
return Cache::remember("url:{$key}", $ttl - 30, fn () => $this->storage->url($key, $ttl));
If it does not support signing, you now know the package is a partial solution; route signed URLs to the underlying provider SDK and treat that as the fork you own.
Map config deliberately. Keep config/filesystems.php as the single source of truth and translate into the bundle's keys inside the service provider. Two competing config trees is how a staging bucket gets hit by a local test.
Contract tests you own. Write one test suite that runs against both Storage::fake() and the real adapter in staging, asserting the same behaviour for put/get/exists/delete/url. These tests are what make a future migration to plain Flysystem a mechanical change rather than a rewrite.
The Symfony/Laravel container boundary is the first wall. A Symfony bundle ships a DependencyInjection\Extension and a Bundle class; neither boots in a Laravel service container without help. Symptoms are Target [integrated_storage.config] is not instantiable or parameter-bag errors. Fix by bypassing the extension — construct adapters directly from a Laravel config array — rather than trying to fake a Symfony ContainerBuilder.
config:dump-reference doesn't exist in Laravel. You cannot introspect the config tree with an Artisan command. Read Configuration.php manually, or boot the extension in a throwaway script with Symfony's Processor to dump defaults. Budget an hour for this; it's the fastest route to a working config.
Flysystem major-version conflict is the most likely hard stop. Laravel 9+ uses Flysystem 3. A package written against Flysystem 2 will fail at composer update with constraint conflicts, and monkey-patching the adapter API is not worth it. Run composer why-not league/flysystem ^3.0 before anything else.
No tags means silent drift. dev-master can be force-pushed. Pin a commit SHA, mirror the repo, and add a calendar reminder to diff the mirror quarterly. If a required fix lands upstream, vendor the patch into your fork rather than floating the constraint.
Silent fallback to ephemeral disk. The single most dangerous failure mode in this category: a caught exception that falls back to local writes user data into a container filesystem that vanishes on redeploy. Grep the adapter for catch blocks. Add a boot-time assertion in production that the resolved driver is the remote one.
Path traversal and cross-tenant leakage. Any package that builds object keys from inputs is a candidate. Test explicitly with ../, URL-encoded %2e%2e%2f, absolute paths, and a key belonging to tenant A requested by tenant B. Enforce the tenant prefix in your gateway layer — do not rely on the package to do it.
Memory profile on large uploads. Measure, don't assume. Push a 500 MB file through and watch memory_get_peak_usage(). If it buffers, it's disqualified for that path and you should route large objects through the provider SDK directly.
Debugging strategy. Log the resolved driver class, the target bucket/root, and the final generated key at debug level in your gateway. When something "isn't there", you need to know instantly whether it went to the wrong disk, the wrong prefix, or nowhere. Without an upstream issue tracker, your own logs are the only forensic trail.
Extension points to prefer. The driver/adapter interface is the intended seam — decorate it rather than subclassing. A LoggingStorageAdapter and a TenantScopedStorageAdapter that both wrap the package's adapter give you observability and isolation without touching vendor code, and both survive a later swap to native Flysystem.
Ramp-up and ownership. Expect a senior engineer to spend one to two sprints reaching confident productivity, largely because there is no documentation, no tests to read, and no community to ask. Name an owner before the first commit lands, and write the internal README yourself as a spike deliverable — it's cheaper than re-deriving the config tree a second time. If the package turns out to be a thin wrapper over Flysystem, the honest comparison is against ~200 lines of tenant-aware disk configuration you own outright; at that point, building thin usually wins.
How can I help you explore Laravel packages today?