Otimizei as imagens do meu Blog Laravel com WebP, Filament e Docker
Fala dev, beleza? Se liga como identifiquei a lentidão no meu blog e como corrigi esse B.O.!
Sabe quando você entra na home do blog e a página parece que tá carregando numa conexão de internet discada?
Pois é, fui investigar e descobri que o grande vilão era um clássico: imagens pesadas.
A home listava vários posts com thumbnails, e cada imagem gigante adicionava tempo de carregamento e sugava a banda do servidor. A experiência do usuário tava simplesmente indo pro ralo.
Até tinha uma tentativa de otimização no projeto, mas era aquele famoso "funciona, mas nem tanto". Algumas thumbnails continuavam com vários megabytes. O pior? As versões menores eram geradas, mas mantinham a extensão original. Ou seja, um PNG gigante virava um PNG menor, quando um WebP seria infinitamente mais leve.
Pra piorar, o container PHP até tinha a biblioteca GD instalada, mas sem suporte real a WebP, e as imagens servidas pelo diretório de storage não tinham cache configurado no Nginx.
Aí resolvi colocar a mão na massa e montar uma arquitetura de respeito.
A Solução: ImageOptimizationService
A primeira coisa foi criar um serviço dedicado, o meu famoso ImageOptimizationService.
A ideia era simples: receber o caminho de uma imagem, criar versões menores e salvar tudo em WebP, configurei três contextos de tamanho (small, medium e large) para thumbnails, imagens de destaque e imagens completas.
private const SIZES = [
'thumbnail' => ['small' => 300, 'medium' => 600, 'large' => 900],
'featured' => ['small' => 400, 'medium' => 800, 'large' => 1200],
'full' => ['small' => 600, 'medium' => 1200, 'large' => 1800],
];
A função principal localiza a imagem e gera essas versões, retornando os metadados pra eu salvar bonitinho no banco de dados:
public function optimizeOnUpload(string $filePath, string $context = 'thumbnail'): array
{
$disk = Storage::disk('public');
$filePath = ltrim($filePath, '/');
$fullPath = $disk->path($filePath);
if (!file_exists($fullPath)) {
throw new \Exception("Arquivo não encontrado: {$filePath}");
}
try {
$sizes = self::SIZES[$context] ?? self::SIZES['thumbnail'];
$optimizedImages = [];
foreach ($sizes as $sizeKey => $width) {
$optimizedImages[$sizeKey] = $this->createOptimizedVersion(
$fullPath,
$filePath,
$width,
$sizeKey
);
}
return [
'original' => $filePath,
'optimized' => $optimizedImages,
'context' => $context,
'sizes' => $sizes,
];
} catch (\Exception $e) {
\Log::error('Erro ao otimizar imagem', [
'file' => $filePath,
'error' => $e->getMessage(),
]);
throw $e;
}
}
Intervention Image + ImageMagick
Para a conversão em si, usei o pacote Intervention Image com o driver GD. O esquema reduz a largura sem distorcer e converte para WebP com qualidade 82, que é o ponto ideal entre tamanho do arquivo e resolução:
private function createWithIntervention(string $source, string $destination, int $width): bool
{
try {
$manager = new ImageManager(new Driver());
$manager
->read($source)
->scaleDown(width: $width) // Redimensiona apenas se a imagem for maior que $width
->toWebp(quality: 82)
->save($destination);
return file_exists($destination) && filesize($destination) > 0;
} catch (\Throwable $e) {
\Log::warning('Intervention não conseguiu otimizar imagem', [
'source' => $source,
'error' => $e->getMessage(),
]);
return false;
}
}
E se o Intervention der ruim? Sem problemas, deixei o ImageMagick configurado como fallback, rodando direto no terminal com parâmetros que limpam metadados desnecessários:
private function createWithImageMagick(string $source, string $destination, int $width): bool
{
$binary = trim((string) shell_exec('command -v magick || command -v convert'));
if ($binary === '') {
return false;
}
$command = sprintf(
'%s %s -auto-orient -strip -resize %dx -quality 82 %s 2>&1',
escapeshellcmd($binary),
escapeshellarg($source),
$width,
escapeshellarg($destination)
);
shell_exec($command);
return file_exists($destination) && filesize($destination) > 0;
}
Evitando Reprocessamento
Outro ponto crucial foi evitar reprocessamento. Criei verificações para garantir que o sistema não tente otimizar o que já tá otimizado, evitando bizarrices como gerar um arquivo logo-small-small.webp:
public function hasOptimizedVersions(string $imagePath, string $context = 'thumbnail'): bool
{
if ($imagePath === '' || str_contains($imagePath, 'http' )) {
return false;
}
$disk = Storage::disk('public');
$sizes = self::SIZES[$context] ?? self::SIZES['thumbnail'];
$pathInfo = pathinfo(ltrim($imagePath, '/'));
$directory = $pathInfo['dirname'];
$filename = $pathInfo['filename'];
foreach (array_keys($sizes) as $sizeKey) {
if (!$disk->exists("{$directory}/{$filename}-{$sizeKey}.webp")) {
return false;
}
}
return true;
}
public function isGeneratedVersion(string $file): bool
{
return (bool) preg_match('/-(small|medium|large)\.[a-z0-9]+$/i', $file);
}
Gerenciamento via Filament
Como eu gerencio isso no dia a dia?
Criei uma tela lindona no painel do Filament!..... Fica lá em "Ferramentas → Otimizar Imagens", mostrando contadores do que já foi processado, posso disparar a otimização com um clique, sem precisar abrir o terminal pra rodar comando Artisan.
O método optimizeDirectory() faz o trabalho pesado, iterando por todos os arquivos de um diretório e processando apenas os que precisam:
public function optimizeDirectory(string $directory, string $context = 'thumbnail', bool $force = false): int
{
$disk = Storage::disk('public');
$optimized = 0;
foreach ($disk->files($directory) as $file) {
if (!$this->isOptimizableImage($file) || $this->isGeneratedVersion($file)) {
continue;
}
if (!$force && $this->hasOptimizedVersions($file, $context)) {
continue;
}
try {
$this->optimizeOnUpload($file, $context);
$optimized++;
} catch (\Throwable $e) {
\Log::error('Erro ao otimizar imagem do diretório', [
'file' => $file,
'error' => $e->getMessage(),
]);
}
}
return $optimized;
}
Gerando Srcset para o HTML
Pra fechar com chave de ouro, criei métodos pra gerar o srcset e sizes direto no HTML, permitindo que o navegador escolha a melhor versão da imagem:
public function generateSrcset(string $imagePath, string $context = 'thumbnail'): string
{
if ($imagePath === '' || str_contains($imagePath, 'http' )) {
return '';
}
$disk = Storage::disk('public');
$sizes = self::SIZES[$context] ?? self::SIZES['thumbnail'];
$imagePath = ltrim($imagePath, '/');
$pathInfo = pathinfo($imagePath);
$directory = $pathInfo['dirname'];
$filename = $pathInfo['filename'];
$srcsetParts = [];
foreach ($sizes as $sizeKey => $width) {
$optimizedPath = "{$directory}/{$filename}-{$sizeKey}.webp";
if ($disk->exists($optimizedPath)) {
$srcsetParts[] = $disk->url($optimizedPath) . " {$width}w";
}
}
return implode(', ', $srcsetParts);
}
public function generateSizes(string $context = 'thumbnail'): string
{
return match ($context) {
'thumbnail' => '(max-width: 640px) 300px, (max-width: 1024px) 600px, 900px',
'featured' => '(max-width: 640px) 400px, (max-width: 1024px) 800px, 1200px',
'full' => '(max-width: 640px) 600px, (max-width: 1024px) 1200px, 1800px',
default => '100vw',
};
}
Isso permite usar no Blade assim:
<img
src="{{ $imageOptimizationService->getOptimizedUrl($post->image, 'medium') }}"
srcset="{{ $imageOptimizationService->generateSrcset($post->image, 'featured') }}"
sizes="{{ $imageOptimizationService->generateSizes('featured') }}"
alt="Post thumbnail"
/>
Docker com Suporte Real a WebP
Claro que o ambiente também precisava de um trato. Fui no Dockerfile e instalei as libs corretas, recompilando a extensão GD do PHP com suporte oficial a WebP:
RUN apt-get install -y libjpeg62-turbo-dev libfreetype6-dev libwebp-dev imagemagick
RUN docker-php-ext-configure gd --with-freetype --with-jpeg --with-webp \
&& docker-php-ext-install gd
E pra fechar com chave de ouro, configurei um cache explícito no Nginx para a pasta de storage, deixando as imagens cacheadas no navegador do usuário por trinta dias:
location /storage/ {
alias /var/www/app/public/storage/;
try_files $uri $uri/ =404;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
access_log off;
}
Link do código: https://github.com/yuri-spm/ImageOptimizationService
O Resultado
Imagens que antes pesavam megabytes caíram para algumas dezenas de kilobytes. O site agora voa no mobile, a banda do servidor agradece, e eu não dependo mais de otimização manual pra cada post novo. A estrutura ficou reutilizável e escalável.
E aí, como você costuma lidar com o peso das imagens nos seus projetos?
Já teve que fazer uma refatoração parecida?
Deixa aí nos comentários!
Comentários
Realize login para comentar neste post