Ofertas de Fin de Verano

Todos los planes rebajados hasta el 31 de agosto.

Ver planes
00 Días
00 Horas
00 Minutos
00 Segundos

Ofertas de Fin de Verano

Precios rebajados en todos los planes, para nuevas suscripciones y renovaciones. Solo hasta el 31 de agosto.
Ver planes
Saltar al contenido

La API de imágenes de Laravel llevaba bien la escritura desde la 13.20: coges una subida, la transformas y la dejas en un disco. La lectura estaba menos resuelta. Servir una imagen redimensionada por HTTP significaba llamar a toBytes(), construir la respuesta y poner el content type a mano, tres líneas de ceremonia en cada controlador que lo hiciera.

Laravel 13.25 hace que Image implemente el contrato Responsable, con lo que una instancia de imagen es un valor de retorno válido desde una ruta o un controlador. En la misma versión han entrado dos añadidos relacionados, Image::fromStream() y un toFormat() público, y entre los tres cubren casi todo lo que necesita un endpoint de imágenes.

Requisitos

  • Laravel 13.25 o superior, publicada el 11 de agosto de 2026.

  • La API de imágenes de primera parte, disponible desde la 13.20.

Los ejemplos van sobre un caso que conozco bien: la portada de un curso, que hay que servir en tres tamaños distintos según dónde aparezca.

Devolver una imagen

<?php

use App\Models\Course;
use Illuminate\Support\Facades\Image;

Route::get('/cursos/{course}/portada', function (Course $course) {
    return Image::fromStorage($course->cover_path)
        ->cover(640, 360)
        ->toWebp()
        ->quality(75);
});

Eso es todo. El framework llama a toResponse(), que ejecuta el pipeline, devuelve los bytes procesados con un 200 y pone el Content-Type a partir de la salida y no del origen. La ruta de arriba devuelve image/webp aunque el archivo guardado sea un JPEG, porque la cabecera se lee de lo que produjo el pipeline.

Cualquier cosa que devuelva un Image funciona igual: un método de controlador, un controlador invocable o un valor devuelto desde un closure de route model binding. La instancia es perezosa hasta que algo pide los bytes, así que la transformación no se ejecuta mientras el framework solo está decidiendo qué tipo de respuesta tiene entre manos.

Cabeceras de caché

La respuesta por defecto no lleva ninguna cabecera de caché, que es el valor correcto para un framework y el equivocado para un endpoint que redimensiona una imagen en cada petición. Llama tú a toResponse() cuando quieras añadir algo:

<?php

Route::get('/cursos/{course}/portada', function (Request $request, Course $course) {
    return Image::fromStorage($course->cover_path)
        ->cover(640, 360)
        ->toWebp()
        ->quality(75)
        ->toResponse($request)
        ->setMaxAge(2592000)
        ->setPublic();
});

toResponse() devuelve un Illuminate\Http\Response, así que tienes toda la API de respuesta disponible: header(), setEtag(), setLastModified() y el resto. Combina un max-age largo con una URL que cambie cuando cambie la imagen, ya sea un hash en la ruta o una query construida con el updated_at del modelo, y los navegadores dejan de preguntar después de la primera petición.

Para cualquier cosa con tráfico real, redimensionar en cada petición sigue siendo trabajo repetido. El patrón que escala es escribir el archivo derivado en la primera petición y servirlo desde disco a partir de ahí:

<?php

Route::get('/cursos/{course}/miniatura', function (Course $course) {
    $filename = "{$course->slug}-{$course->updated_at->timestamp}.webp";
    $path = "covers/{$filename}";

    if (! Storage::disk('public')->exists($path)) {
        Image::fromStorage($course->cover_path)
            ->cover(320, 180)
            ->toWebp()
            ->quality(70)
            ->storeAs('covers', $filename, 'public');
    }

    return Storage::disk('public')->response($path);
});

Meter el timestamp en el nombre del archivo hace que una portada actualizada produzca una ruta nueva, así que las miniaturas viejas dejan de usarse sin tener que invalidar ninguna caché.

Formatos dinámicos con toFormat()

Un endpoint que acepta el formato desde la petición necesitaba antes un match para convertir el string en la llamada al método correcto. toFormat() ahora es público y recibe el formato directamente:

<?php

Route::get('/cursos/{course}/portada.{format}', function (Course $course, string $format) {
    return Image::fromStorage($course->cover_path)
        ->scale(width: 1280)
        ->toFormat($format)
        ->quality(75);
})->where('format', 'avif|webp|jpg');

Los valores admitidos son webp, jpg, jpeg, png, gif, avif, heic, heif y bmp, con heif normalizado a heic. Cualquier otra cosa lanza una ImageException con el formato en el mensaje, y eso es un 500 y no un 404, así que restringe el parámetro en la ruta como arriba o valida el valor antes de pasarlo. El mismo método está detrás de optimize(), que es al que recurrir cuando además quieres fijar la calidad en una sola llamada.

Esto es lo que hace corto un endpoint de AVIF con fallback: sirve el formato que pida la petición y deja que el elemento <picture> decida a qué URL llama el navegador.

Construir desde un stream

Image::fromStream() crea una instancia a partir de un recurso de stream, que cubre los orígenes que los demás métodos de fábrica no alcanzan:

<?php

$image = Image::fromStream(Storage::disk('s3')->readStream($course->cover_path));

La lectura es perezosa. fromStream() envuelve el recurso en un closure y no lo toca hasta que se ejecuta el pipeline, así que crear una instancia que al final no usas no cuesta nada. Un stream que no devuelve datos lanza una ImageException en ese momento, no al construir.

Junto a fromPath(), fromStorage(), fromUpload(), fromUrl(), fromBytes() y fromBase64(), la variante de stream es la indicada para cualquier cosa de la que ya tengas un handle abierto: un cuerpo php://input en un endpoint de subida en crudo, un archivo que estás leyendo de un zip o un stream que te pasa otra librería.

Un endpoint completo

Juntando las tres piezas, un endpoint que sirve la portada de un curso desde S3, con width y format configurables y caché de un año:

<?php

use App\Models\Course;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Image;
use Illuminate\Support\Facades\Storage;

Route::get('/cursos/{course}/imagen', function (Request $request, Course $course) {
    $validated = $request->validate([
        'width' => ['integer', 'between:160,1600'],
        'format' => ['in:avif,webp,jpg'],
    ]);

    return Image::fromStream(Storage::disk('s3')->readStream($course->cover_path))
        ->scale(width: $validated['width'] ?? 640)
        ->toFormat($validated['format'] ?? 'webp')
        ->quality(75)
        ->toResponse($request)
        ->setMaxAge(31536000)
        ->setPublic();
})->middleware('signed');

Hay dos detalles que conviene mantener. El ancho está acotado, porque una dimensión sin validar en un endpoint público es una invitación a pedir un redimensionado de 20.000 píxeles. Y la ruta va firmada, lo que impide que cualquiera genere variantes arbitrarias contra tu factura de almacenamiento.

school Curso completo

Curso Laravel 12
Completo 2026

El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.

access_time 8 horas de contenido
layers 4 tecnologías en 1
update 100% actualizado
code Proyectos prácticos
Ver Curso Laravel 12 arrow_forward

star Incluido en cualquier suscripción

Rutas de aprendizaje