Blog

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!

Wsparcie istniejącego systemu

Potrzebujesz pomocy z działającą aplikacją?

Pomagam firmom rozwijać działające systemy, porządkować wdrożenia i dodawać nowe funkcje bez dokładania chaosu do projektu.

Komentarze (0)
Zaloguj się, aby dodać komentarz

Musisz być zalogowany, aby dodać komentarz.

Zaloguj się

Potrzebujesz kogoś, kto weźmie odpowiedzialność za kolejny krok?

Porozmawiajmy o Twoim projekcie i określmy zakres, który ma sens dla Twoich celów.