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

Bunnynet Php Laravel Package

toshy/bunnynet-php

A PHP SDK for bunny.net that simplifies working with its CDN, storage, and DNS APIs. Manage pull zones, storage zones, and related resources from any PHP application through a clean, lightweight client.

Deep Wiki
Context7

Getting Started

Install

composer require toshy/bunnynet-php
composer require guzzlehttp/guzzle   # or symfony/http-client

The package is HTTP-transport agnostic: it talks to bunny.net through PSR-18 (ClientInterface) and PSR-17 factories. Guzzle implements both, so it's the shortest path. PHP 8.x is required.

Where to look first

  1. README.md – construction example and the list of API "sections" exposed by the client.
  2. src/ – one class/enum per bunny.net API area (pull zones, edge storage, stream, DNS, purge, statistics, billing). The method names track the official bunny.net API docs almost 1:1, so once you've grepped the accessor names you can work from bunny's own documentation.
  3. tests/ – the fixtures there are the fastest way to see exact request paths, query params, and response shapes.

The client is constructed from an HTTP client plus your account API key, and exposes the API areas as section accessors. Accessor and method names below are illustrative; confirm the exact spellings once in src/ and never look again.

Laravel wiring (do this on day one)

config/services.php:

'bunny' => [
    'api_key'          => env('BUNNY_API_KEY'),
    'storage_zone'     => env('BUNNY_STORAGE_ZONE'),
    'storage_password' => env('BUNNY_STORAGE_PASSWORD'),
    'storage_region'   => env('BUNNY_STORAGE_REGION', 'de'),
    'stream_library'   => env('BUNNY_STREAM_LIBRARY_ID'),
    'stream_key'       => env('BUNNY_STREAM_API_KEY'),
],

AppServiceProvider::register():

$this->app->singleton(\ToshY\BunnyNet\BunnyClient::class, function () {
    $http = new \GuzzleHttp\Client([
        'timeout'         => 15,
        'connect_timeout' => 5,
    ]);

    return new \ToshY\BunnyNet\BunnyClient($http, config('services.bunny.api_key'));
});

First use case: purge the CDN after a deploy

use ToshY\BunnyNet\BunnyClient;

public function __invoke(BunnyClient $bunny)
{
    $bunny->purge()->purgePullZone((int) config('services.bunny.pull_zone_id'));
}

That is the 80% case: content changes, you purge a URL or a whole pull zone. Everything else (edge storage uploads, Stream video, DNS records) is a variation on the same pattern.

Implementation Patterns

1. Wrap the vendor client in your own service

Do not let ToshY\BunnyNet\* types leak into controllers and models. One thin adapter gives you a place to add logging, retries, and a fake for tests.

namespace App\Services\Cdn;

class BunnyCdn
{
    public function __construct(private \ToshY\BunnyNet\BunnyClient $bunny) {}

    public function purgeUrl(string $url): void
    {
        $this->bunny->purge()->purgeUrl(urlencode($url));
    }

    public function purgeZone(int $zoneId): void
    {
        $this->bunny->purge()->purgePullZone($zoneId);
    }
}

2. Purge as a side effect of model changes

The most common integration: content update → purge the corresponding URL. Never purge synchronously inside a web request that a user waits on (bunny purges are eventually consistent and the round trip is 100–300 ms).

class ImageObserver
{
    public function saved(Image $image): void
    {
        PurgeBunnyUrl::dispatch($image->cdnUrl())->onQueue('cdn');
    }
}

Batch purges inside a deploy/job so you make one API call per zone rather than one per asset. purgePullZone on a zone with millions of objects is cheap; the per-URL endpoint is rate-limited.

3. Edge Storage uploads from queued jobs

Edge Storage is the S3-ish layer in front of a storage zone. It has its own credentials (storage zone password) and its own region-specific hostname — not the account API key.

public function handle()
{
    $stream = fopen($this->localPath, 'rb');

    $this->bunny
        ->edgeStorage($this->zone, $this->region, $this->password)
        ->uploadFile($this->remotePath, $stream);
}

Use a deterministic remote path (/users/{id}/avatar.webp) so uploads are idempotent and re-runs overwrite rather than duplicate. After a successful upload the object is reachable via the pull zone hostname, usually within seconds.

4. Stream video: create → upload → webhook

For bunny Stream, the flow is always three steps, and the third one is asynchronous:

  1. Create the video record via the API, get back a GUID.
  2. PUT the binary to the returned upload endpoint (raw body, not multipart).
  3. Wait for the encoding webhook (Status: 4 = finished) before exposing the player.

Persist the GUID as the primary identifier for your local video row. Treat "uploaded" and "encoded" as separate states in your own model — the API has no way to block until encoding is done.

5. Signed URLs / token authentication

Bunny's token auth is not an API call, and most of the time the library won't do it for you. Implement it as a helper:

public function signed(string $path, int $ttl = 3600): string
{
    $expires = time() + $ttl;
    $token   = hash('sha256', $this->tokenKey . $path . $expires);

    return $this->pullZoneHost . $path
        . '?token=' . rtrim(strtr(base64_encode($token), '+/', '-_'), '=')
        . '&expires=' . $expires;
}

Keep this out of the delivery path if you can — sign in the view/URL generator, cache the result.

6. Multiple zones / multi-tenant

One BunnyClient per account key is fine, but edge storage and Stream are scoped per zone/library, so build a tiny factory that memoises per-zone instances:

class BunnyFactory
{
    private array $storage = [];

    public function storage(string $zone): object
    {
        return $this->storage[$zone] ??= $this->client->edgeStorage(
            $zone,
            config('services.bunny.storage_region'),
            config("services.bunny.zones.{$zone}.password"),
        );
    }
}

7. Statistics for dashboards

Statistics endpoints are per-zone, per-period, and expensive to poll. Pull once per hour in a scheduled command, store the numbers locally, and render from your own table. Never call them from a page view.

8. Testing

Because the library takes a PSR-18 client in its constructor, you can bind a mock in the container and assert on the wire format:

$mock = new \GuzzleHttp\Handler\MockHandler([
    new \GuzzleHttp\Psr7\Response(204),
]);
$history = [];
$handler = \GuzzleHttp\HandlerStack::create($mock);
$handler->push(\GuzzleHttp\Middleware::history($history));

$this->app->instance(BunnyClient::class, new BunnyClient(
    new \GuzzleHttp\Client(['handler' => $handler]),
    'test-key',
));

// ... exercise code ...

$this->assertSame('/purge', $history[0]['request']->getUri()->getPath());

This is far more valuable than mocking your own wrapper: it catches wrong paths, missing query params, and encoding bugs (URLs must be URL-encoded before being passed to the purge endpoint).

Gotchas and Tips

Credentials are not interchangeable. bunny.net has at least four distinct secrets: the account API key (pull zones, DNS, billing, statistics, purge), the storage zone password (edge storage), the Stream library API key, and the origin/pull-zone token key (signing). A 401 usually means you used the right shape of key in the wrong place, not that the key is wrong.

Edge Storage is region-scoped and the region is in the hostname. de, ny, la, sg, etc. A wrong region gives you a timeout or DNS failure, not a clean auth error, which makes it look like a network problem. Put the region in config explicitly and default it — don't infer it.

Storage zone name is part of the path, not the host or the headers. If you get 404 on an upload that you're sure is correct, check the path composition first.

Uploads are raw-body PUTs. file_get_contents() on a 500 MB video will blow your memory limit. Always fopen($path, 'rb') and pass the resource. Guzzle will stream it. Set Content-Type deliberately; bunny stores it and serves it back.

Timeouts: Guzzle's default is 0, i.e. wait forever. In a queue worker that means a hung job, a stuck worker, and a cascading outage. Set timeout and connect_timeout explicitly on the underlying client, and set a lower timeout for purges (which are fire-and-forget) than for uploads.

There is no built-in retry. Handle 429 and 5xx yourself with exponential backoff. Guzzle's RetryMiddleware or Laravel's retry() around your wrapper both work; don't retry 4xx other than 429, and never retry a non-idempotent POST blindly.

Exceptions carry the response body — log it. Non-2xx responses are thrown. bunny's error bodies contain a human-readable message ({"message":"..."}) that tells you exactly what's wrong (bad zone ID, malformed DNS record, quota exceeded). Catching the exception and logging only getMessage() on the exception class often loses it. Log $e->getResponse()->getBody().

Purges are eventually consistent. A 204 means "accepted", not "done". If you write an integration test that purges then immediately fetches, it will be flaky. If you build a "purge and verify" feature, sleep/poll with a generous budget.

Pagination shape is consistent but not uniform. Most list endpoints return items plus totalItems / currentPage / hasMoreItems. Loop until hasMoreItems is false, and set perPage as high as the endpoint allows (often 1000) — the default is usually 100, which turns a simple "list all files" into dozens of round trips.

Deleting a storage zone requires it to be empty. Same for some Stream operations. Do the cleanup in a job with progress tracking, not in a request.

DNS updates can be read-modify-write. Some record operations replace the full record set. Fetch, mutate, put back — and be aware that a concurrent change will be silently overwritten.

Stream encoding is asynchronous and failure is silent. Videos can end in a failed state and no webhook will retry. Poll the video status on a schedule as a safety net, or you will have rows stuck in "processing" forever. Bunny does not sign Stream webhooks — protect the endpoint with an unguessable path segment or an IP allowlist, and verify the library/video GUID exists in your DB before acting.

Don't call env() outside config. Standard Laravel, but it bites here because people put the API key directly in a service provider and then wonder why config:cache breaks it in production.

Debug at the HTTP layer. Guzzle's Middleware::log() (or a Telescope/profiler integration) shows you the exact request bunny received, which is the only reliable way to debug signature and encoding problems. Add it behind an env flag; remove it from production hot paths because it will happily log your storage password.

Pin the version. The library tracks a fast-moving API and ships frequently (last release 2026-08-23). Pin a minor version in composer.json and read the changelog before bumping — bunny itself occasionally changes response shapes for statistics and Stream.

Extension points. You get three clean seams without forking: the injected PSR-18 client (timeouts, retries, logging), your own wrapper service (domain-specific methods, caching, test doubles), and the HTTP middleware stack. Almost every "the library doesn't do X" problem is better solved in the wrapper than in a fork — the wrapper survives upgrades.

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.
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route