Obrazy to jedno z najczęstszych wąskich gardeł wydajnościowych w aplikacjach Laravel. Przechowywanie oryginałów jest proste. Zmiana rozmiaru w locie jest kosztowna. Dostarczanie WebP do nowoczesnych przeglądarek z fallbackiem na JPEG dla starszych to rodzaj rzeczy, których zbudowanie od zera zajmuje tydzień. spatie/laravel-medialibrary rozwiązuje warstwę przechowywania i transformacji, a z Intervention Image 3.x jako sterownikiem, dostajesz nowoczesne, type-safe API do przetwarzania obrazów PHP. Ten artykuł obejmuje cały pipeline: upload, konwersję, optymalizację i serwowanie.
📋 Spis treści
- 📦 Instalacja i konfiguracja
- 🔗 Trait HasMedia - dołączanie plików do modeli
- 🖼️ Definiowanie konwersji - zmiana rozmiaru, WebP, miniatura
- ⚙️ Intervention Image 3.x jako sterownik
- 📁 Własny dysk i strategia ścieżek
- 🔄 Generowanie konwersji w Queue Jobs
- 🌐 Responsywne obrazy - srcset bez dodatkowej pracy
- 🚀 Senior Twist: WebP z fallbackiem JPEG - serwowanie właściwego formatu
- ✅ Podsumowanie
📦 Instalacja i konfiguracja
composer require spatie/laravel-medialibrary intervention/image-laravel
php artisan vendor:publish --provider="Spatie\MediaLibrary\MediaLibraryServiceProvider" --tag="medialibrary-migrations"
php artisan migrate
Zainstaluj adapter Intervention Image dla Laravel:
composer require intervention/image-laravel
php artisan vendor:publish --provider="Intervention\Image\Laravel\ServiceProvider"
Skonfiguruj Intervention Image w config/image.php - ustaw sterownik:
// config/image.php
return [
'driver' => \Intervention\Image\Drivers\Gd\Driver::class,
// Lub Imagick dla lepszej jakości:
// 'driver' => \Intervention\Image\Drivers\Imagick\Driver::class,
];
W config/media-library.php skonfiguruj dysk i kolejkę:
// config/media-library.php
return [
'disk_name' => env('MEDIA_DISK', 'public'),
'max_file_size' => 1024 * 1024 * 10, // 10 MB
'queue_connection_name' => env('QUEUE_CONNECTION', 'redis'),
'queue_name' => 'media-conversions',
'queue_conversions_by_default' => env('QUEUE_MEDIA_CONVERSIONS', true),
'media_model' => Spatie\MediaLibrary\MediaCollections\Models\Media::class,
'image_driver' => Intervention\Image\Laravel\Facades\Image::class,
'path_generator' => null, // Nadpiszemy to poniżej
];
🔗 Trait HasMedia - dołączanie plików do modeli
Dowolny model Eloquent może mieć media. Dodaj interfejs HasMedia i trait InteractsWithMedia:
// app/Models/Post.php
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class Post extends Model implements HasMedia
{
use InteractsWithMedia;
protected $fillable = ['title', 'content', 'status'];
protected function casts(): array
{
return [
'published_at' => 'datetime',
];
}
public function registerMediaCollections(): void
{
$this->addMediaCollection('featured-image')
->singleFile() // Tylko jeden obrazek wyróżniający per post
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp'])
->withResponsiveImages(); // Auto-generuj warianty srcset
$this->addMediaCollection('gallery')
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
}
public function registerMediaConversions(Media $media = null): void
{
$this->addMediaConversion('thumb')
->width(400)
->height(300)
->sharpen(10)
->format('webp')
->performOnCollections('featured-image', 'gallery');
$this->addMediaConversion('hero')
->width(1200)
->height(630)
->format('webp')
->performOnCollections('featured-image');
$this->addMediaConversion('og-image')
->width(1200)
->height(630)
->format('jpg')
->quality(85)
->performOnCollections('featured-image');
}
}
Upload pliku do kolekcji:
// app/Http/Controllers/Api/v1/PostController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\v1;
use App\Http\Controllers\Api\ApiController;
use App\Http\Requests\PostStoreRequest;
use App\Http\Resources\PostResource;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends ApiController
{
public function store(PostStoreRequest $request): PostResource
{
$post = Post::create($request->validated());
if ($request->hasFile('featured_image')) {
$post
->addMediaFromRequest('featured_image')
->usingName($request->validated('title'))
->toMediaCollection('featured-image');
}
return new PostResource($post->load('media'));
}
public function addGalleryImage(Request $request, Post $post): PostResource
{
$request->validate([
'image' => ['required', 'image', 'max:10240'],
]);
$post
->addMediaFromRequest('image')
->toMediaCollection('gallery');
return new PostResource($post->load('media'));
}
}
Upload z URL lub ścieżki (przydatne przy importach):
// Z URL
$post->addMediaFromUrl('https://example.com/image.jpg')
->usingName('Imported Image')
->toMediaCollection('gallery');
// Z lokalnej ścieżki
$post->addMedia('/tmp/uploaded-image.jpg')
->preservingOriginal()
->toMediaCollection('gallery');
// Z ciągu base64
$post->addMediaFromBase64($base64String)
->usingFileName('profile.jpg')
->toMediaCollection('gallery');
🖼️ Definiowanie konwersji - zmiana rozmiaru, WebP, miniatura
Konwersje są definiowane w registerMediaConversions() na modelu. Każda konwersja generuje osobny plik na dysku podczas dodawania media (lub po uruchomieniu komendy artisan).
// app/Models/Product.php
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class Product extends Model implements HasMedia
{
use InteractsWithMedia;
public function registerMediaCollections(): void
{
$this->addMediaCollection('product-images')
->acceptsMimeTypes(['image/jpeg', 'image/png', 'image/webp'])
->withResponsiveImages();
}
public function registerMediaConversions(Media $media = null): void
{
// Mała miniatura dla widoków siatki
$this->addMediaConversion('thumb')
->width(300)
->height(300)
->fit(\Spatie\Image\Enums\Fit::Crop)
->format('webp')
->quality(80)
->performOnCollections('product-images');
// Średni dla list produktów
$this->addMediaConversion('medium')
->width(600)
->height(600)
->fit(\Spatie\Image\Enums\Fit::Contain)
->background('ffffff')
->format('webp')
->quality(85)
->performOnCollections('product-images');
// Duży dla strony szczegółów produktu
$this->addMediaConversion('large')
->width(1200)
->height(1200)
->fit(\Spatie\Image\Enums\Fit::Contain)
->background('ffffff')
->format('webp')
->quality(90)
->performOnCollections('product-images');
// Fallback JPEG dla maila/tagów OG
$this->addMediaConversion('og')
->width(1200)
->height(630)
->fit(\Spatie\Image\Enums\Fit::Crop)
->format('jpg')
->quality(85)
->performOnCollections('product-images');
}
}
Dostęp do przekonwertowanych obrazów:
$product = Product::find(1);
// Oryginalny
$product->getFirstMediaUrl('product-images');
// Konkretna konwersja
$product->getFirstMediaUrl('product-images', 'thumb');
$product->getFirstMediaUrl('product-images', 'medium');
// Pobierz model Media, aby mieć dostęp do wszystkich konwersji
$media = $product->getFirstMedia('product-images');
$media->getUrl('thumb');
$media->getPath('thumb'); // Ścieżka dysku, nie URL
// Sprawdź czy konwersja jest wygenerowana
$media->hasGeneratedConversion('thumb'); // bool
Regenerowanie konwersji po zmianie ich definicji:
# Regeneruj wszystkie konwersje dla wszystkich modeli
php artisan media-library:regenerate
# Dla konkretnego modelu
php artisan media-library:regenerate --model="\App\Models\Product"
# Dla konkretnych ID mediów
php artisan media-library:regenerate --ids=1,2,3
⚙️ Intervention Image 3.x jako sterownik
Intervention Image 3.x wprowadził nową architekturę sterowników z osobnymi sterownikami GD i Imagick. API jest czyste, chainowalne i w pełni typowane.
Pakiet medialibrary używa Intervention Image wewnętrznie do konwersji. Możesz go też używać bezpośrednio do własnej manipulacji przed dodaniem do biblioteki:
// app/Services/ImageProcessingService.php
<?php
declare(strict_types=1);
namespace App\Services;
use Intervention\Image\Laravel\Facades\Image;
class ImageProcessingService
{
public function processAvatar(string $sourcePath, string $targetPath): void
{
Image::read($sourcePath)
->cover(400, 400) // Przytnij do kwadratu, wyśrodkowany
->sharpen(5)
->toWebp(quality: 90)
->save($targetPath);
}
public function addWatermark(string $sourcePath, string $watermarkPath, string $targetPath): void
{
$image = Image::read($sourcePath);
$watermark = Image::read($watermarkPath)->scale(width: 200);
$image
->place($watermark, 'bottom-right', offsetX: 15, offsetY: 15)
->toJpeg(quality: 85)
->save($targetPath);
}
public function generatePlaceholder(int $width, int $height, string $color = 'e5e7eb'): string
{
return Image::create($width, $height)
->fill($color)
->toJpeg()
->toDataUri();
}
}
📁 Własny dysk i strategia ścieżek
Domyślnie media są przechowywane w {model_type}/{model_id}/{media_id}/{filename}. Możesz to dostosować za pomocą PathGenerator:
// app/Media/CustomPathGenerator.php
<?php
declare(strict_types=1);
namespace App\Media;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
use Spatie\MediaLibrary\Support\PathGenerator\PathGenerator;
class CustomPathGenerator implements PathGenerator
{
public function getPath(Media $media): string
{
// Organizuj według kolekcji i daty dla łatwiejszego zarządzania
$date = $media->created_at->format('Y/m');
return "{$media->collection_name}/{$date}/{$media->id}/";
}
public function getPathForConversions(Media $media): string
{
$date = $media->created_at->format('Y/m');
return "{$media->collection_name}/{$date}/{$media->id}/conversions/";
}
public function getPathForResponsiveImages(Media $media): string
{
$date = $media->created_at->format('Y/m');
return "{$media->collection_name}/{$date}/{$media->id}/responsive/";
}
}
Zarejestruj w config/media-library.php:
'path_generator' => \App\Media\CustomPathGenerator::class,
Używanie S3 na produkcji:
// config/filesystems.php
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
],
MEDIA_DISK=s3
Z dyskiem s3, wszystkie pliki media i konwersje są przechowywane w S3, a URL-e są automatycznie publicznymi URL-ami CDN.
🔄 Generowanie konwersji w Queue Jobs
Z 'queue_conversions_by_default' => true, konwersje są generowane asynchronicznie przez workera kolejki. Oznacza to, że po uploaderze, oryginał jest od razu dostępny, ale konwersje zajmują chwilę.
Job kolejki to Spatie\MediaLibrary\Conversions\Jobs\PerformConversions. Uruchom dedykowanego workera:
php artisan queue:work redis --queue=media-conversions
Sprawdzanie statusu konwersji z API:
// app/Http/Resources/MediaResource.php
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class MediaResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'file_name' => $this->file_name,
'mime_type' => $this->mime_type,
'size' => $this->size,
'original' => $this->getUrl(),
'conversions' => [
'thumb' => $this->hasGeneratedConversion('thumb')
? $this->getUrl('thumb')
: null,
'medium' => $this->hasGeneratedConversion('medium')
? $this->getUrl('medium')
: null,
'large' => $this->hasGeneratedConversion('large')
? $this->getUrl('large')
: null,
],
'conversions_ready' => $this->hasGeneratedConversion('thumb')
&& $this->hasGeneratedConversion('medium'),
];
}
}
Ręczne uruchamianie konwersji:
// Wymuś natychmiastowe generowanie konwersji (omiń kolejkę)
$media->performConversions();
// Lub wyślij do konkretnej kolejki
\Spatie\MediaLibrary\Conversions\Jobs\PerformConversions::dispatch(
$media,
$media->model->getMediaConversions()
)->onQueue('high-priority');
🌐 Responsywne obrazy - srcset bez dodatkowej pracy
Dodanie ->withResponsiveImages() do kolekcji instruuje bibliotekę, aby generowała wiele wariantów rozmiarów dla srcset. Przeglądarki pobierają wtedy odpowiedni rozmiar na podstawie viewportu i gęstości pikseli urządzenia.
// W registerMediaCollections()
$this->addMediaCollection('featured-image')
->withResponsiveImages();
Generuje to warianty o szerokościach jak 340, 510, 680, 1020, 1360, 2040... pikseli (na podstawie oryginalnych wymiarów).
Używanie responsywnych obrazów w Blade:
{{-- resources/views/posts/show.blade.php --}}
@if($post->hasMedia('featured-image'))
{{ $post->getFirstMedia('featured-image')->img('hero', ['class' => 'w-full', 'alt' => $post->title]) }}
@endif
Generuje to:
<img
src="https://cdn.example.com/featured-image/2026/03/1/hero.webp"
srcset="
https://cdn.example.com/featured-image/2026/03/1/responsive/hero___340.jpg 340w,
https://cdn.example.com/featured-image/2026/03/1/responsive/hero___510.jpg 510w,
https://cdn.example.com/featured-image/2026/03/1/responsive/hero___680.jpg 680w
"
sizes="(max-width: 768px) 100vw, 1200px"
class="w-full"
alt="My Post Title"
>
W odpowiedziach API (dla frontendów React/Vue):
// app/Http/Resources/PostResource.php
'featured_image' => $this->getFirstMedia('featured-image') ? [
'url' => $this->getFirstMediaUrl('featured-image', 'hero'),
'thumb' => $this->getFirstMediaUrl('featured-image', 'thumb'),
'srcset' => $this->getFirstMedia('featured-image')->getSrcset('hero'),
'alt' => $this->title,
] : null,
🚀 Senior Twist: WebP z fallbackiem JPEG - serwowanie właściwego formatu
WebP oferuje 25-35% mniejsze pliki niż JPEG przy tej samej jakości wizualnej. Ale niektóre starsze przeglądarki (i niektórzy klienci email) nie obsługują WebP. Rozwiązanie: generuj oba formaty i serwuj właściwy.
Strategia 1: Generuj obie konwersje i wybierz w kodzie:
// W registerMediaConversions()
$this->addMediaConversion('hero-webp')
->width(1200)
->height(630)
->format('webp')
->quality(85)
->performOnCollections('featured-image');
$this->addMediaConversion('hero-jpg')
->width(1200)
->height(630)
->format('jpg')
->quality(85)
->performOnCollections('featured-image');
// W zasobie API - niech klient wybierze
'featured_image' => [
'webp' => $this->getFirstMediaUrl('featured-image', 'hero-webp'),
'jpg' => $this->getFirstMediaUrl('featured-image', 'hero-jpg'),
],
Frontend React/Vue używa elementu <picture>:
<picture>
<source type="image/webp" :srcset="post.featured_image.webp" />
<img :src="post.featured_image.jpg" :alt="post.title" />
</picture>
Strategia 2: Serwuj przez Nginx z negocjacją nagłówka Accept:
# nginx.conf
location ~* \.(jpg|jpeg|png)$ {
add_header Vary Accept;
if ($http_accept ~* "webp") {
rewrite ^(.*)\.(jpg|jpeg|png)$ $1.webp break;
add_header Content-Type image/webp;
}
}
Strategia 3: Zwróć właściwy format z kontrolera na podstawie nagłówka Accept:
// app/Http/Controllers/Api/v1/MediaController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\v1;
use App\Http\Controllers\Api\ApiController;
use App\Models\Post;
use Illuminate\Http\Request;
class MediaController extends ApiController
{
public function featuredImage(Request $request, Post $post): \Illuminate\Http\JsonResponse
{
$acceptsWebP = str_contains($request->header('Accept', ''), 'image/webp');
$conversion = $acceptsWebP ? 'hero-webp' : 'hero-jpg';
return $this->success('Image URL', [
'url' => $post->getFirstMediaUrl('featured-image', $conversion),
'format' => $acceptsWebP ? 'webp' : 'jpg',
]);
}
}
Wskazówki optymalizacji obrazów:
- Używaj
->quality(80)dla WebP - 80 jest wizualnie bezstratne dla większości obrazów - Używaj
->quality(85)dla JPEG - 85 to złoty środek jakości vs rozmiaru - Używaj
->sharpen(5)po zmianie rozmiaru - zmiana rozmiaru rozmywa obrazy, lekkie wyostrzenie kompensuje - Używaj
->fit(Fit::Crop)dla miniatur gdzie wymiary muszą być dokładne,Fit::Containdla obrazów produktów aby unikać przycinania - Włącz
->withResponsiveImages()tylko dla obrazów hero/featured - generowanie 8 wariantów srcset dla każdego zdjęcia galerii mnoży koszty przechowywania
✅ Podsumowanie
- Zainstaluj
spatie/laravel-medialibrary+intervention/image-laravel; skonfiguruj sterownik GD lub Imagick w zależności od możliwości serwera - Dodaj interfejs
HasMedia+ traitInteractsWithMediado dowolnego modelu; definiuj kolekcje wregisterMediaCollections()i konwersje wregisterMediaConversions() - Używaj
->singleFile()dla zdjęć profilowych i obrazów wyróżniających; pomiń dla kolekcji galerii - Definiuj nazwane konwersje per kolekcja (
thumb,medium,large,og) z jawnym formatem i jakością - Ustaw
'queue_conversions_by_default' => truei uruchom dedykowanego workera kolejkimedia-conversions; eksponujhasGeneratedConversion()w odpowiedziach API - Używaj
->withResponsiveImages()na kolekcjach obrazów hero/featured, aby dostać srcset za darmo - Generuj zarówno konwersje
webpjak ijpg; używaj<picture>na frontendzie lub negocjacji nagłówka Accept Nginx - Używaj własnego
PathGenerator, aby organizować media według daty zamiast domyślnej struktury model-type/model-id
Obserwuj mnie na LinkedIn po więcej porad Laravel! Czy serwujesz WebP na produkcji? Jak wygląda Twój pipeline optymalizacji obrazów? Daj znać w komentarzach poniżej!