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.

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 viatemporaryUrl().
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 R2Bewaar 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:
- Tijdelijke links in S3-stijl. Gebruik
Storage::disk('r2')->temporaryUrl(). Eenvoudig en snel. Intrekken gebeurt door de link snel te laten verlopen (minuten). - Backend proxy route. Autoriseer in Laravel, stream dan vanaf R2 met de referenties van je app. Hiermee kun je
Content-Dispositioninstellen(inline vs. bijlage) en toegang centraal loggen. - 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
- Achtergrondconversies in Laravel. Upload het origineel, zet een taak in de wachtrij, genereer afmetingen/formaten, gebruik gevingerafdrukte namen zoals
photo@800w.a1b2c3.jpg. - Rand transformaties. Formaat van publieke previews wijzigen aan de rand (Workers/Images) en het resultaat cachen op het CDN.
- Hybride. Veel voorkomende formaten aan de rand; exotische formaten in achtergrondjobs. Met namen met een vingerafdruk kun je
immutableveilig 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
PutObjecttoestaan voorbuild/*naar CI;Put/Get/List/Deletetoestaan voormedia/*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.tsof repo.
Veel voorkomende valkuilen
- MIME types voor lettertypes/afbeeldingen. Zorg voor correcte
Content-Typeop objecten - browsers zijn kieskeurig overfont/woff2enimage/svg+xml. - "Kleverig" manifest. Houd een korte TTL aan en upload het manifest als laatste.
- Niet overeenkomende paden. Vite's
basemoet 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
SignatureDoesNotMatchfouten 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
ETagbeschikbaar.
Checklist voor implementatie
- Maak een bucket en schakel Public Bucket in; koppel een Custom Domain.
- Installeer de S3 adapter en configureer de
r2(of gesplitste public/private) schijven metendpoint,use_path_style_endpoint=true, enurlingesteld op je CDN domein. - Gesplitste voorvoegsels:
build/,media/,private/. - Schakel Vite → R2 in: stel
basein op het CDN; voer twee uploads uit (assets → manifest); geef de voorkeur aan path-style client. - Stel headers in: hashed assets -
immutable + 1y; manifest - korte TTL; media - praktische TTL (bijv. uren-dagen). - Optioneel: achtergrond / rand afbeelding conversies.
- Isoleer app en CI referenties. Voeg CORS toe voor je app origin.
- 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
ETagbloot, bevestigContent-Type. - 404 na implementatie: waarschijnlijk een
basemismatch of je hebtmanifest.jsongeü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).