Optymalizacja Obrazów w Laravel: WebP, Responsive Images i Automatyczna Konwersja

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

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::Contain dla 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 + trait InteractsWithMedia do dowolnego modelu; definiuj kolekcje w registerMediaCollections() i konwersje w registerMediaConversions()
  • 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' => true i uruchom dedykowanego workera kolejki media-conversions; eksponuj hasGeneratedConversion() w odpowiedziach API
  • Używaj ->withResponsiveImages() na kolekcjach obrazów hero/featured, aby dostać srcset za darmo
  • Generuj zarówno konwersje webp jak i jpg; 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!

Komentarze (0)
Zostaw komentarz

© 2026 Wszelkie prawa zastrzeżone.