softavis/flysystem-cloudflare-bundle
A Symfony bundle that integrates Flysystem with Cloudflare R2 object storage, making it easy to configure R2 as a filesystem for uploads, downloads, and file management through familiar Flysystem APIs.
Reality check before installation. This package is a Symfony bundle (-bundle suffix, Symfony DI extension + config tree). Laravel has no Symfony Flex bundle auto-discovery, so the bundle wrapper — service definitions, Extension class, config tree — is inert in a Laravel app. The reusable asset is the Flysystem adapter class underneath. Confirm early whether the maintainer also publishes a standalone softavis/flysystem-cloudflare adapter package (the bundle almost certainly depends on it); if it does, install that directly and skip this package entirely.
Minimal steps (spike, timeboxed to one day):
composer require "softavis/flysystem-cloudflare-bundle:0.1.2"
vendor/softavis/flysystem-cloudflare-bundle/ — find the classes registered in Resources/config/services.yaml and the adapter constructor signature. That is the API surface that actually matters.composer why league/flysystem
composer show -t softavis/flysystem-cloudflare-bundle
https://<account-id>.r2.cloudflarestorage.com) and region: auto.First use case — bypass the bundle. Do not try to boot the Symfony bundle inside Laravel. Instead register the adapter as a custom disk driver in an app-owned service provider, exposing it behind Storage::disk('r2'):
// app/Providers/FilesystemServiceProvider.php
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Facades\Storage;
use League\Flysystem\Filesystem;
use Softavis\FlysystemCloudflare\CloudflareAdapter; // verify actual namespace
public function boot(): void
{
Storage::extend('r2', function ($app, array $config) {
$adapter = new CloudflareAdapter(
$config['bucket'],
$config['account_id'],
$config['access_key_id'],
$config['secret_access_key'],
$config['endpoint'] ?? null,
);
return new FilesystemAdapter(new Filesystem($adapter), $adapter, $config);
});
}
// config/filesystems.php
'r2' => [
'driver' => 'r2',
'bucket' => env('R2_BUCKET'),
'account_id' => env('R2_ACCOUNT_ID'),
'access_key_id' => env('R2_ACCESS_KEY_ID'),
'secret_access_key' => env('R2_SECRET_ACCESS_KEY'),
],
Then exercise the four call patterns you will actually ship — put, get, temporaryUrl, delete — against the bucket before writing any domain code.
Where to look first, honestly. The best reference implementation is not this package — it is league/flysystem-aws-s3-v3 pointed at an R2 endpoint with region: auto, which is the well-trodden path and takes ~10 lines of config. Build that working disk first as a permanent fallback, then evaluate this adapter against it on the specific gaps (signed URL ergonomics, multipart tuning, public/custom-domain URLs).
Pattern: adapter-only consumption, bundle ignored. The bundle's DI wiring is dead weight in Laravel; consume only the adapter class, either by installing it directly (preferred) or by referencing the class from the bundle package. Never invoke the Symfony Extension or config tree — Laravel's service container does not know about them, and trying to bridge them creates more surface area than the adapter itself.
Pattern: app-owned driver behind a contract test. Own the registration in FilesystemServiceProvider, and add a Pest/PHPUnit contract test that runs the same assertions against both your R2 disk and the AWS-S3-adapter fallback disk. This keeps the swap-out live and turns an upstream break into a failing test rather than a production incident:
it('stores and retrieves files', function (string $disk) {
Storage::disk($disk)->put('probe.txt', 'ok');
expect(Storage::disk($disk)->get('probe.txt'))->toBe('ok');
Storage::disk($disk)->delete('probe.txt');
})->with(['r2', 'r2-aws-fallback']);
Pattern: thin internal facade over Storage. Domain code should only ever call Storage::disk('assets') (or a FileStore interface wrapping it), never adapter-specific methods. The moment a controller calls $adapter->somethingCloudflareSpecific(), the config-change swap becomes a refactor.
Pattern: env-parity disks. Keep local for dev and r2 for staging/prod, but drive both through the same domain-facing interface. This surfaces visibility and temporaryUrl divergence (see gotchas) in CI rather than in prod.
Pattern: signed-URL generation in one place. If you serve private assets, centralise temporaryUrl / presigned logic in a service that switches between the R2 adapter and the AWS-S3 fallback based on a config flag. The two adapters have subtly different signatures; isolate that.
Pattern: multipart for large uploads. R2 is S3-compatible; if the adapter exposes multipart configuration, tune part size for your typical asset (e.g. 10–25 MB chunks for video). If it does not expose it, the AWS S3 adapter does — another data point for the fallback path.
Workflow: one-day evaluation spike. (1) Read the 0.1.1→0.1.2 diff directly — release notes are a bare compare URL, so the diff is the changelog. (2) Stand up both disks. (3) Exercise upload/read/delete/visibility/temporaryUrl on each. (4) Quantify what the Softavis adapter gives you that S3-adapter-on-R2 does not. (5) Only promote if the delta is concrete.
This is a Symfony bundle, not a Laravel package. The strongest signal in the repo name is that it targets Symfony. Laravel will not auto-register its service provider, will not discover its DI extension, and will not read its config tree. If you install it expecting a filesystems driver to appear, it will not. Extract the adapter class explicitly (see Getting Started).
Pre-1.0 semver means patch releases can break. At 0.1.x, 0.1.3 is permitted to change a constructor signature. Always pin to "0.1.2" exactly; never ^0.1 — that resolves breaking changes under the guise of patch bumps.
The 0.1.2 release is opaque. There are no release notes, just a compare link. This is a process signal, not a feature signal. Read https://github.com/softavis/flysystem-cloudflare-bundle/compare/0.1.1...0.1.2 before upgrading, every time.
R2 is S3-compatible, so the "need" for this package is narrow. The first-party league/flysystem-aws-s3-v3 with an R2 endpoint and region: auto is the best-supported path. This adapters only justifies itself if it adds something the S3 adapter lacks — presigned-URL convenience, public-bucket URL generation, multipart tuning, or Cloudflare-Images-specific helpers. Verify which Cloudflare product it targets (R2 vs Images vs Stream); the release notes do not say.
Credential/endpoint misconfigures surface as opaque 403s at runtime, not boot. Add a health-check command that does a head (or exists) on a sentinel object and runs in your deploy pipeline:
if (! Storage::disk('r2')->exists('.healthcheck')) {
throw new RuntimeException('R2 disk unreachable or misconfigured');
}
Visibility and temporaryUrl diverge between local and r2. Local dev will happily return a public URL for a private object; R2 will not. Same for directory-listing consistency. If a test passes on local and 403s on r2, assume visibility semantics, not a bug in your code.
Transitive dependency conflicts are the common failure mode. A pre-1.0 Symfony-oriented package pulling Symfony components into a Laravel app is a classic source of version conflicts with Laravel's own Symfony dependencies. Run composer why on any Symfony packages the bundle brings in before committing.
Bus-factor and abandonment risk are real at this size. Single maintainer, thin release communication, no evident CI matrix or security policy. Mirror the repo internally (or pin a commit hash) so an upstream force-push or deletion is not an outage. If no one on your team can read the adapter source, that is the deciding argument against adoption at 0.1.x.
Extension point — if you do adopt it: wrap the adapter in an app-owned decorator implementing a small FileStore interface. That gives you a single seam to swap in the AWS S3 adapter (or a different provider) without touching domain code, and a single place to add observability, retries, and the health check above.
Bottom line for a Laravel developer: the default path is the AWS S3 adapter on R2. Treat this package as an evaluated-not-default option: pin it, spike it against a real bucket, and only adopt it if it demonstrably adds capability you need — behind your own driver registration and contract test so removal stays a config change.
How can I help you explore Laravel packages today?