Cloudflare R2 + Laravel: goedkope mediaopslag zonder compromissen

R2 is een S3-compatibele opslag met voorspelbare prijzen en een CDN op één klik afstand. Deze gids laat zien hoe je het in Laravel inbedt voor zowel publieke als private bestanden, hoe je de juiste edge caching instelt en hoe je je Vite build direct naar R2 verzendt met echt atomische releases.

Cloudflare R2 + Laravel: goedkope mediaopslag zonder compromissen

Waarom R2 past bij Laravel projecten

Bijna elk product stelt dezelfde vragen: waar bewaar je gebruikersbestanden, hoe betaal je niet te veel voor bandbreedte, hoe zorg je ervoor dat afbeeldingen en scripts direct aankomen en hoe geef je de frontend vrij zonder "gebroken" links. R2 spreekt het bekende S3-dialect en koppelt netjes met een wereldwijd CDN, wat minimale wijzigingen in je app betekent en een korte weg naar ideale caching headers. Als je activa vandaag vanaf je server serveert en ze morgen naar de rand verplaatst, is de overgang soepel en met weinig risico.

Natuurlijk heeft elke infrastructurele "magie" zijn nadelen. Een strakke CDN-integratie is fantastisch voor de snelheid en voor goedkope, cache-vriendelijke releases, maar je moet je houden aan de regels van de provider (bijvoorbeeld hoe publieke toegang wordt beheerd) en een paar S3-aardigheden opgeven. Hieronder staat een eerlijke blik op wat je uit de doos krijgt en waar een beetje aanpassing helpt.

Waar R2 schittert - en waar het tekortschiet

  • Sterke punten. S3 compatibiliteit zonder wrijving; assets worden geserveerd vanaf een nabijgelegen CDN POP onder je aangepaste domein; voorspelbare uitgang bij gebruik van het CDN; perfect geschikt voor gehashte assets, statische sites, publieke previews en mediablobs.
  • Afwegingen. Geen 1:1 overeenkomst met klassieke S3 (bijv. publieke toegang wordt geconfigureerd op bucket/domein niveau in plaats van via per-object ACL headers); het bredere ecosysteem van derden van de grootste S3 aanbieders is nog steeds groter; de allerbeste voordelen komen wanneer je de CDN koppeling van de aanbieder omarmt.

Wat dit in de praktijk betekent: behandel openbare bestanden als cachebare edge content (onveranderlijke namen, lange TTL), behandel privébestanden als ondertekende S3 verzoeken (kortstondige links of een backend proxy) en push je build artefacten naar de bucket met de headers die je wilt dat gebruikers zien.

R2 concepten in 2 minuten

  • Bucket: een container voor je objecten. Voor openbare levering koppel je een aangepast domein aan de emmer en zet je de schakelaar voor openbare toegang om op emmerniveau.
  • Eindpunt: S3-compatibel eindpunt zoals https://<ACCOUNT_ID>.r2.cloudflarestorage.com. SDK's praten hiermee; browsers gebruiken meestal je aangepaste domein (CDN).
  • Publiek vs privaat: publieke objecten worden gewoon opgehaald via URL via CDN; private objecten worden opgevraagd via ondertekende verzoeken (tijdelijke S3-handtekening) of geserveerd door je eigen backend/Worker.
  • Caching: edge POP's eren je objectheaders (Cache-Control, ETag, Content-Type). Onveranderlijke namen = nul ongeldigmakingen.

R2 bedraden in Laravel

Voorwaarde (S3 adapter voor Flysystem):

composer require league/flysystem-aws-s3-v3:^3.0

Configureer een gewone S3 schijf (de belangrijke bits zijn de endpoint en pad-stijl toegang):

// config/filesystems.php
'disks' => [
    'r2' => [
        'driver' => 's3',
        'key' => env('R2_KEY'),
        'secret' => env('R2_SECRET'),
        'region' => 'auto',
        'bucket' => env('R2_BUCKET'),
        'endpoint' => env('R2_ENDPOINT'), // https://<ACCOUNT_ID>.r2.cloudflarestorage.com
        'use_path_style_endpoint' => true,
        'url' => env('R2_PUBLIC_BASE'),   // https://assets.example.com (your CDN domain)
        'visibility' => 'private',
    ],
],
# .env
R2_KEY=...
R2_SECRET=...
R2_BUCKET=your-bucket
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
R2_PUBLIC_BASE=https://assets.example.com

Gebruik het zoals je elke S3 schijf zou gebruiken:

// Public file
Storage::disk('r2')->put('media/avatars/u123.jpg', $binary);
$url = Storage::disk('r2')->url('media/avatars/u123.jpg');

// Private file (serve via a temporary signed URL)
$link = Storage::disk('r2')->temporaryUrl(
    'private/invoice-2025-09.pdf',
    now()->addMinutes(5)
);

Patroon met twee schijven (optioneel maar netjes)

Om de intentie expliciet te maken, maak je r2_public en r2_private schijven met verschillende basis URL's en standaard headers bij het uploaden:

// config/filesystems.php
'disks' => [
  'r2_public' => [
    'driver' => 's3',
    'key' => env('R2_KEY'),
    'secret' => env('R2_SECRET'),
    'region' => 'auto',
    'bucket' => env('R2_BUCKET'),
    'endpoint' => env('R2_ENDPOINT'),
    'use_path_style_endpoint' => true,
    'url' => env('R2_PUBLIC_BASE'),
    'visibility' => 'public',
  ],
  'r2_private' => [
    'driver' => 's3',
    'key' => env('R2_KEY'),
    'secret' => env('R2_SECRET'),
    'region' => 'auto',
    'bucket' => env('R2_BUCKET'),
    'endpoint' => env('R2_ENDPOINT'),
    'use_path_style_endpoint' => true,
    'visibility' => 'private',
  ],
],

Publiek vs privé: splitsen door voorvoegsels

Een eenvoudige, duurzame conventie:

  • build/ - frontend assets (JS/CSS/fonts/icons), openbaar, "voor altijd" gecached.
  • media/ - openbare afbeeldingen/voorvertoningen, gematigde TTL.
  • private/ - gesloten documenten en originelen, alleen geserveerd via temporaryUrl().

Waarom voorvoegsels? Ze maken automatisering van de levenscyclus triviaal: headers bij het uploaden, CDN regels en toegangsbeleid kunnen zich richten op eenvoudige paden. Bovendien zijn je cleanup en rollbacks veiliger als assets gegroepeerd zijn.

Edge cache: welke headers in te stellen

  • Voor gehashte assets (bijv. app.78e2a3.js): Cache-Control: public, max-age=31536000, immutable
  • Voor manifesten, configs, JSON en andere "live" bestanden: Cache-Control: public, max-age=60

Stel headers in bij het uploaden zodat ze aan het object blijven plakken. Browsers + CDN zullen ook ETag respecteren en 304s serveren wanneer nodig. Onveranderlijke naamgeving elimineert cache ongeldigmakingen.

Inhoudstype is belangrijk (lettertypen/afbeeldingen)

Browsers zijn kieskeurig met font/woff2 en image/svg+xml. Geef bij het uploaden expliciet ContentType door:

Storage::disk('r2_public')->put(
  'build/assets/app.78e2a3.woff2',
  $bytes,
  ['visibility' => 'public', 'ContentType' => 'font/woff2', 'CacheControl' => 'public, max-age=31536000, immutable']
);

CORS voor lettertypen en XHR

Als je app draait op app.example.com en assets op assets.example.com, configureer dan CORS op de bucket zodat fonts en JSON fetches zonder fouten worden geladen:

[
  {
    "AllowedMethods": ["GET", "HEAD", "OPTIONS"],
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedHeaders": ["*"],
    "ExposeHeaders": ["ETag", "Content-Length"],
    "MaxAgeSeconds": 86400
  }
]

Stuur je Vite build rechtstreeks naar R2 (atomische releases)

Het idee: draai npm run build in CI en laat een S3-compatibele Vite uploader public/build naar R2 pushen. Het pad van je emmer en de openbare URL moeten overeenkomen met base van Vite.

Omgevingsvariabelen

R2_ACCOUNT_ID=xxxxxxxxxxxxxxxxxxxx
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=my-cdn
R2_PUBLIC_BASE=https://assets.example.com          # CDN domain
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
UPLOAD_ENABLED=1                                    # enable upload in CI

Vite configuratie

Belangrijkste punten: stel base in op CDN-domein + /build/; gebruik padachtige toegang voor de S3-client; vertrouw niet op ACL's voor publieke toegang (configureer in plaats daarvan emmer/domein); voer twee uploads uit voor atomiciteit - assets eerst (lange TTL), dan manifest.json (korte TTL).

// vite.config.ts
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
import { ViteS3 } from '@froxz/vite-plugin-s3'

const enabled = !!process.env.UPLOAD_ENABLED

const clientConfig = {
  region: 'auto',
  endpoint: process.env.R2_ENDPOINT!,
  forcePathStyle: true, // safer for R2
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
}

export default defineConfig({
  base: `${process.env.R2_PUBLIC_BASE}/build/`,
  build: { manifest: true, outDir: 'public/build', emptyOutDir: true },
  plugins: [
    laravel({
      input: ['resources/js/app.ts', 'resources/css/app.css'],
      refresh: true,
      buildDirectory: 'build',
    }),

    // 1) Hashed assets — long TTL + immutable
    ViteS3(enabled, {
      directory: 'public/build',
      basePath: '/build',
      include: [
        /\/assets\/.+\.[a-f0-9]{8,}\.(js|css|woff2|png|jpe?g|webp|svg|gif|ico|map)$/i
      ],
      clientConfig,
      uploadOptions: {
        Bucket: process.env.R2_BUCKET!,
        CacheControl: 'public, max-age=31536000, immutable',
      },
    }),

    // 2) manifest.json — short TTL; uploaded after assets
    ViteS3(enabled, {
      directory: 'public/build',
      basePath: '/build',
      include: [/manifest\.json$/],
      sequentialUploads: true,
      clientConfig,
      uploadOptions: {
        Bucket: process.env.R2_BUCKET!,
        CacheControl: 'public, max-age=60',
      },
    }),
  ],
})

CI pijplijn

npm ci
npm run build    # the uploader pushes the build to R2

Bewaar UPLOAD_ENABLED=0 lokaal en schakel het alleen in CI in. Bewaar alle geheimen van R2_* in de geheimmanager van je CI.

Rollbacks die echt werken

Omdat assets onveranderlijk zijn, is terugdraaien gewoon een manifest switch. Bewaar de vorige manifest.json; om terug te rollen, upload je het oudere manifest opnieuw (korte TTL) terwijl de assets in de cache blijven staan met lange TTL. Geen ongeldigmakingen, geen wachten.

Privé leveringspatronen

Gebruik een van deze voor gevoelige bestanden:

  1. Tijdelijke links in S3-stijl. Gebruik Storage::disk('r2')->temporaryUrl(). Eenvoudig en snel. Intrekken gebeurt door de link snel te laten verlopen (minuten).
  2. Backend proxy route. Autoriseer in Laravel, stream dan vanaf R2 met de referenties van je app. Hiermee kun je Content-Disposition instellen(inline vs. bijlage) en toegang centraal loggen.
  3. Cloudflare Worker. Een kleine randfunctie die een JWT/sessiecookie valideert en ophaalt van R2, waardoor het binaire verkeer aan de rand blijft.

Voorbeeld: Laravel proxy controller

// routes/web.php
Route::get('/files/{path}', App\Http\Controllers\PrivateFileController::class)
  ->where('path', '.*')
  ->middleware(['auth']);

// app/Http/Controllers/PrivateFileController.php
public function __invoke(string $path)
{
    abort_unless(Str::startsWith($path, 'private/'), 404);

    $stream = Storage::disk('r2_private')->readStream($path);
    abort_unless($stream, 404);

    return response()->stream(function () use ($stream) {
        fpassthru($stream);
    }, 200, [
        'Content-Type' => Storage::disk('r2_private')->mimeType($path) ?? 'application/octet-stream',
        'Content-Disposition' => 'inline', // or 'attachment'
        'Cache-Control' => 'private, max-age=0, no-store',
    ]);
}

Voorbeeld: minimaal Worker idee

Pseudocode: verifieer een ondertekend token (HMAC/JWT), haal private/<key> op van R2 met service credentials, stel Content-Disposition in, stream terug. Het resultaat leeft in de buurt van de gebruiker en raakt nooit je PHP runtime.

Beeldtransformaties: drie solide opties

  1. Achtergrondconversies in Laravel. Upload het origineel, zet een taak in de wachtrij, genereer afmetingen/formaten, gebruik gevingerafdrukte namen zoals photo@800w.a1b2c3.jpg.
  2. Rand transformaties. Formaat van publieke previews wijzigen aan de rand (Workers/Images) en het resultaat cachen op het CDN.
  3. Hybride. Veel voorkomende formaten aan de rand; exotische formaten in achtergrondjobs. Met namen met een vingerafdruk kun je immutable veilig bewaren.

Tip: welke benadering je ook kiest, bewaar de afgeleide URL (of het pad) die je daadwerkelijk rendert. Dat voorkomt verrassende herberekeningen en houdt je sjablonen eenvoudig.

Beveiliging en toegang

  • Splits referenties. Eén voor de web app (lees/schrijf specifieke prefixen), een andere voor CI (schrijf alleen naar build/).
  • Principe van de minste privileges. Alles weigeren, dan PutObject toestaan voor build/* naar CI; Put/Get/List/Delete toestaan voor media/* naar de app als je bewerkingen nodig hebt.
  • Regio/jurisdictie. gebruik de juiste eindpuntvariant als je emmer is vergrendeld voor een specifieke jurisdictie om problemen met handtekeningen/omleidingen te voorkomen.
  • Geheimen hygiëne. bewaar sleutels in env/CI kluizen, nooit in vite.config.ts of repo.

Veel voorkomende valkuilen

  • MIME types voor lettertypes/afbeeldingen. Zorg voor correcte Content-Type op objecten - browsers zijn kieskeurig over font/woff2 en image/svg+xml.
  • "Kleverig" manifest. Houd een korte TTL aan en upload het manifest als laatste.
  • Niet overeenkomende paden. Vite's base moet overeenkomen met het werkelijke CDN-pad (bijv. https://assets.example.com/build/…).
  • Klokscheefstand & handtekeningen. Temp URL's kunnen mislukken als de klok van je server verschuift. NTP lost mysterieuze SignatureDoesNotMatch fouten op.
  • Verkeerde adresseringsmodus. R2 geeft de voorkeur aan pad-stijl (forcePathStyle). Virtueel gehoste verzoeken kunnen 403/redirect.
  • CORS. Als fonts mislukken op Safari/Firefox, voeg dan CORS toe voor je app origin en stel ETag beschikbaar.

Checklist voor implementatie

  1. Maak een bucket en schakel Public Bucket in; koppel een Custom Domain.
  2. Installeer de S3 adapter en configureer de r2 (of gesplitste public/private) schijven met endpoint, use_path_style_endpoint=true, en url ingesteld op je CDN domein.
  3. Gesplitste voorvoegsels: build/, media/, private/.
  4. Schakel Vite → R2 in: stel base in op het CDN; voer twee uploads uit (assets → manifest); geef de voorkeur aan path-style client.
  5. Stel headers in: hashed assets - immutable + 1y; manifest - korte TTL; media - praktische TTL (bijv. uren-dagen).
  6. Optioneel: achtergrond / rand afbeelding conversies.
  7. Isoleer app en CI referenties. Voeg CORS toe voor je app origin.
  8. Plan rollbacks: bewaar vorig manifest; rollback = opnieuw uploaden oud manifest.

Denkrichting kosten & limieten (niet uitputtend)

Denk in operations en egress. De grootste besparingen ontstaan als het meeste publieke verkeer wordt geserveerd door het CDN met langlevende cache; CI pushes zijn klein vergeleken met de reads van gebruikers. Als je uploads van gebruikers verplaatst naar media/ met een gematigde TTL en de voorkeur geeft aan thumbnails/previews boven originelen in de UI, verminder je bytes en time-to-first-paint. Houd objecttellingen, verzoekpercentages en cache-hitratio bij om verrassingen te voorkomen.

Snelle referentie voor het oplossen van problemen

  • 403 op tijdelijke URL: controleer de klokvertraging, de referenties en of de host van het verzoek overeenkomt met de ondertekende host (SDK ondertekent vaak het eindpunt, niet je CDN-domein).
  • Font geblokkeerd: voeg/relax CORS toe, stel ETag bloot, bevestig Content-Type.
  • 404 na implementatie: waarschijnlijk een base mismatch of je hebt manifest.json geüpload vóór assets.
  • Trage eerste hit: verwacht cold edge cache. Gebruik pre-warm (optioneel) of accepteer de first-miss tax; volgende hits zijn direct.

Conclusie

Als je een snelle voorkant, goedkope opslag en minder bewegende delen wilt, dan levert de Laravel + R2 combo: vertrouwde bestands-API's, CDN levering uit de doos en nette Vite releases zonder gebroken paden. Je geeft een paar S3-specifieke snufjes op, maar in ruil daarvoor krijg je snelheid, eenvoud en voorspelbaarheid van kosten - de dingen die teams dagelijks het meest waarderen.


Neem contact op

Een project in gedachten?

eel je context en gewenst resultaat. Binnen 1 werkdag sturen we de eenvoudigste volgende stap (tijdlijn, ruwe begroting of snelle audit).

Door te verzenden ga je ermee akkoord dat we je gegevens verwerken om op je aanvraag te reageren en, indien van toepassing, precontractuele stappen te nemen op jouw verzoek (AVG art. 6(1)(b)) of op basis van ons gerechtvaardigd belang (art. 6(1)(f)). Deel geen bijzondere persoonsgegevens. Zie ons Privacybeleid.
Reactie binnen 1 werkdag.