Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Flysystem Bunnycdn Laravel Package

platformcommunity/flysystem-bunnycdn

A Flysystem adapter for BunnyCDN storage that lets Laravel apps use BunnyCDN as a filesystem disk. Upload, read, delete, and list files through Laravel's Storage facade, with support for Flysystem v3 and BunnyCDN storage zones. Simple to configure and framework-friendly.

View on GitHub
Deep Wiki
Context7

Getting Started

1. Install and pin a recent tag. The adapter has shipped a lot of correctness fixes in the 3.3.x line (temporary URLs, streaming, path prefixing, delete guards). Anything below 3.3.10 is leaving known bugs on the table.

composer require platformcommunity/flysystem-bunnycdn:^3.3.10

This is a Flysystem v3 adapter, not a Laravel package with a bundled service provider. It requires PHP 8.0+ (that became the floor in v3.0.0) and gives you PlatformCommunity\Flysystem\BunnyCDN\BunnyCDNAdapter on top of the BunnyCDNClient. Laravel wiring is your job — see below.

2. Create the Bunny resources and collect four values:

Value Where it comes from Used for
Storage Zone name Bunny dashboard → Storage Zones API path construction
Storage Zone password (API key) Storage Zone → FTP & API access Auth for read/write/delete
Region / main storage endpoint e.g. de, ny, la, sg, syd, br, jh Base URL for the storage API
Pull Zone hostname + token key Pull Zone linked to the zone url() and temporaryUrl()

Note the region codes are case-sensitive-ish in history: v3.3.3 fixed a bug where the API returned an uppercase region and the generated base URL broke. Pass the exact string Bunny shows you.

3. Register the disk. The first place to look is config/filesystems.php plus one Storage::extend() call — there is nothing to auto-discover:

// app/Providers/AppServiceProvider.php  (boot)
Storage::extend('bunnycdn', function ($app, $config) {
    $client = new \PlatformCommunity\Flysystem\BunnyCDN\BunnyCDNClient(
        $config['storage_zone'],
        $config['api_key'],
        $config['region'],
    );

    return new \League\Flysystem\Filesystem(
        new \PlatformCommunity\Flysystem\BunnyCDN\BunnyCDNAdapter($client),
        [
            'url' => $config['pull_zone_url'] ?? null, // enables publicUrl generation (v3.3.0+)
        ],
    );
});
// config/filesystems.php
'disks' => [
    'bunny' => [
        'driver'        => 'bunnycdn',
        'storage_zone'  => env('BUNNYCDN_STORAGE_ZONE'),
        'api_key'       => env('BUNNYCDN_API_KEY'),   // note: older README said BUNNYCDN_APY_KEY
        'region'        => env('BUNNYCDN_REGION', 'de'),
        'pull_zone_url' => env('BUNNYCDN_PULL_ZONE_URL'),
        'throw'         => true,
    ],
],

4. First use case — the one that justifies the switch. Public, CDN-delivered media uploads:

Storage::disk('bunny')->put('products/'.$id.'/hero.jpg', $contents, ['ContentType' => 'image/jpeg']);
$url = Storage::disk('bunny')->url('products/'.$id.'/hero.jpg'); // served from the pull zone, not the storage host

If you only need private, expiring downloads, jump straight to temporaryUrl() (v3.3.8+, query params and pull-zone prefixes in 3.3.9/3.3.10) — that path is the most recently hardened part of the package.

Implementation Patterns

Public vs private is a zone-level decision, not a file-level one. A Bunny storage zone either has a pull zone attached or it doesn't. Practical pattern: two disks, two zones.

  • bunny-public → pull zone attached → url() returns CDN links, cacheable forever.
  • bunny-private → no public pull zone → temporaryUrl() for time-boxed access to invoices, exports, user documents.

Do not rely on visibility('private') to protect a file in a public zone; the storage host serves nothing publicly, but the pull zone will happily serve anything in it.

Signed downloads with expiry and query params (the 3.3.9/3.3.10 additions):

$url = Storage::disk('bunny-private')->temporaryUrl(
    'invoices/2024/'.$invoice->id.'.pdf',
    now()->addMinutes(10),
    ['response-content-disposition' => 'attachment; filename="invoice.pdf"']
);

Because query params now pass through, this is also the hook for Bunny Optimizer / image transformation params on public assets (?width=800&quality=80), letting you skip a separate image-resize service.

Batch uploads — use writeBatch, not a foreach. Added in v3.3.5, it cuts request round-trips when seeding or ingesting:

Storage::disk('bunny')->writeBatch($files); // [path => contents] — much faster for bulk import jobs

Be aware Bunny's API is not transactional: a failed batch leaves partial state. Design import jobs to be idempotent and re-runnable, and treat the storage write as the slowest step in the job — queue it.

Stream large files; do not buffer. True streaming landed in v3.3.6. For video or multi-hundred-MB assets, always go through streams so PHP memory stays flat:

$stream = fopen($request->file('video')->getRealPath(), 'r');
Storage::disk('bunny')->writeStream("videos/{$video->id}.mp4", $stream);

Reading back with readStream() is equally important for proxying downloads through your app. The 3.2.2 fix ("always download file content without jsonDecoding") is the reason binary reads are trustworthy — another argument for pinning 3.3.10+.

Path prefixing via the adapter, not string soup. Since v3.2.0 you can scope the adapter to a subfolder instead of littering every call with a prefix:

new BunnyCDNAdapter($client, ...); // plus Flysystem's own path-prefix / 'root' handling

v3.2.1 ("several broken prefix paths") and v3.2.4 ("replace only the first occurrence of the storage zone name") both fixed prefix bugs. If you're using prefixPath or a non-empty root, verify listing and url() output on a staging zone before shipping — this is the thinnest-tested corner of the adapter.

Directory listings are emulated and can be expensive. Bunny is an object store; there are no real folders. listContents() deep recursion (v3.2.0) issues extra API calls per directory. For "show me every file in the media library," prefer a database index of paths you wrote yourself over repeated listContents($dir, true) calls.

Store the path, derive the URL. Keep key/path columns in your models rather than absolute URLs. It makes switching pull zones, adding a custom CDN domain, or migrating off Bunny a config change instead of a data migration.

Local-then-remote for heavy processing. Write to a local or s3 scratch disk, process (thumbnails, transcoding), then push the final artefact to Bunny for delivery. Mixing the processing disk with the delivery disk keeps cache invalidation trivial when a job re-runs.

Testing pattern. Storage::fake('bunny') swaps the disk for a local temp disk, which is perfect for feature tests but proves nothing about the adapter. For adapter-level behaviour (mimetype quirks, delete guards, temp URLs) run a small contract suite against a throwaway Bunny zone in CI — the project itself added live-environment tests in v3.2.0/v3.3.2, so mirror that approach, and keep those tests out of the default suite so a flaky CDN never blocks a deploy.

Gotchas and Tips

text/plain throws UnableToRetrieveMetadata. This is documented in the 3.1.2 release notes: per the Flysystem spec test fetching_unknown_mime_type_of_a_file, a plain-text file surfaces as an unknown MIME type. If you mimeType() anything user-supplied — especially .txt, .csv, .log — wrap it:

try {
    $mime = Storage::disk('bunny')->mimeType($path);
} catch (\League\Flysystem\UnableToRetrieveMetadata $e) {
    $mime = 'text/plain';
}

This is the single most likely thing to bite you on day one, and it is by design, not a bug to report.

Never call deleteDirectory() on the root or an empty path. v3.3.3 explicitly added a guard to stop a delete operation from wiping all contents of the zone; v3.3.4 then made it ignore NotFoundException on directory deletes. Treat an empty string path as a footgun and validate it out of your own code, especially in admin panels.

Same-source-and-destination moves are guarded. v3.3.4 added "prevent deleting file when moving to same destination." Before that, move($a, $a) could delete the file. Upgrading is cheaper than debugging that.

Datetime handling was inconsistent upstream. v3.1.1 fixed differing datetime formats returned by the API. If you surface lastModified() in a UI, normalise to UTC yourself and don't compare raw strings from the adapter with DB timestamps.

Checksums default to SHA256. v3.3.2 aligned the adapter's checksum with bunny.net's default. If you verify integrity against a hash you computed elsewhere, use SHA256 — a mismatch here looks like data corruption but is usually an algorithm mismatch.

Version-to-version breakage is real and documented. BunnyCDNStorage → BunnyCDNClient was a hard rename (v1.2.0 / v2.2.0) and the README has shipped with the BUNNYCDN_APY_KEY typo. If a disk "silently isn't configured", check the env var name character by character before touching the code.

url() returns nothing unless a URL generator is configured. Public URL generation came via Flysystem's PublicUrlGenerator in v3.3.0 — pass the pull zone URL into the Filesystem config (see Getting Started) or url() will fail. Temporary URLs additionally need the pull zone token key, and the pull-zone-prefix behaviour changed across 3.3.8 → 3.3.9, so re-read the changelog for your exact tag.

No atomicity, no transactions, no rename. Bunny's API is object storage over HTTP: move() is copy-then-delete, and a crash between the two leaves duplicates. For anything that must land atomically, write to a temp key and move() into the final path, then reconcile orphans on a schedule.

Extension points are thin — plan for a fork. At 95 stars and 0 dependents, this is a community package with one primary maintainer and a changelog-driven release cadence (v3.3.10 is recent). If you need custom headers, multipart behaviour, or retry/backoff, the seam is BunnyCDNClient — wrap it in your own decorator implementing the same methods and inject retries/telemetry there, rather than patching the adapter. Keep a composer.json conflict/upper bound on the version so a future minor doesn't land during a deploy.

Debugging order of operations. 401 → wrong storage zone password or zone name. 404 on a path you know exists → prefix/root misconfiguration (see the 3.2.1/3.2.4 history). Correct file, broken URL → pull zone not attached, or region string mismatch producing a wrong base URL (the uppercase-region bug from 3.3.3). Correct URL, stale image → pull zone cache; Bunny caches aggressively, so purge rather than guess.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle
glavweb/api-bundle
glavweb/admin-bundle