Added in v1.5.0.
Every image row now carries its own record of how the optimization pipeline ended, so consumers can render a status badge, show bytes saved, and list the generated formats without recomputing anything at read time.
Persisted columns
The 2026_09_14_000001_add_optimization_columns_to_media_table.php migration adds six nullable columns and one index to the media table:
| Column | Type | Purpose |
|---|---|---|
optimization_status |
string(20) |
One of pending, optimized, failed, or null (N/A). Indexed. |
optimized_at |
timestamp |
When the pipeline finished, or null if not run. |
optimization_bytes_saved |
unsigned big int |
Delta between the original bytes and the optimized bytes. |
optimization_original_size |
unsigned big int |
The pre-optimization size, kept alongside bytes_saved for display. |
optimization_formats |
json |
Map of format → generated (e.g. {"webp": true, "avif": true}). |
optimization_error |
string(500) |
Truncated exception message from the failing run. |
Non-image rows leave every column null. Consumers should treat null as "N/A" rather than "pending."
Status constants
Three constants live on the Media model — always prefer them over hard-coded strings so a future rename doesn't leak into your code:
use ArtisanPackUI\MediaLibrary\Models\Media;
Media::OPTIMIZATION_STATUS_PENDING; // 'pending'
Media::OPTIMIZATION_STATUS_OPTIMIZED; // 'optimized'
Media::OPTIMIZATION_STATUS_FAILED; // 'failed'
How the status is written
MediaProcessingService::processImage() owns the transitions:
- A new upload persists with
optimization_status = null— the pipeline has not run yet, so consumers render this as "N/A" (not "pending"). Applications typically invokeprocessImage()from a subscriber toap.mediaLibrary.uploadedor theMediaUploadedevent, so exactly when the row leavesnulldepends on how that subscriber is wired. - When
processImage()is called on an image, the service writesoptimization_status = pending(along withoptimization_original_size) at method entry, before running the pipeline. - On success, the service overwrites the row with
optimization_status = optimized, setsoptimized_atto the completion timestamp, and populatesoptimization_bytes_saved,optimization_formats, and (already set)optimization_original_size. - On any exception thrown by the pipeline, the service writes
optimization_status = failedand the truncated message tooptimization_error, then rethrows. The exception is still surfaced to the caller — the persisted status is a display aid, not a substitute for handling the error.
The writes are ordinary Eloquent forceFill()->save() calls rather than compare-and-set updates, so concurrent runs on the same row (a retry firing while an original attempt is still in flight) can overwrite each other. In practice processImage() is expected to run at most once per row at a time; if you queue processing, use a single-worker queue or a job-level lock to serialize retries.
Reading the status
From PHP
$media = apGetMedia($mediaId);
if ($media->optimization_status === Media::OPTIMIZATION_STATUS_FAILED) {
// Show a retry affordance, log $media->optimization_error, etc.
}
From the API
MediaResource returns a stable optimization block on every media item — non-image rows carry every field as null, so clients can encode it as a single interface:
{
"id": 42,
"optimization": {
"status": "optimized",
"optimized_at": "2026-09-14T12:00:00Z",
"bytes_saved": 123456,
"original_size": 789012,
"formats": { "webp": true, "avif": true },
"error": null
}
}
From TypeScript (React / Vue)
The shipped types file exports the shape directly:
import type { Media, MediaOptimization, OptimizationStatus } from '@/types/media';
function badgeLabel(optimization: MediaOptimization): string {
switch (optimization.status) {
case 'optimized': return 'Optimized';
case 'pending': return 'Optimizing…';
case 'failed': return 'Failed';
default: return 'N/A';
}
}
Backfilling legacy libraries
Existing pre-1.5 image rows arrive as optimization_status = null, which reads as "N/A" — not "pending." If you want them to render as optimized instead (so a status column doesn't fill with N/A badges), run:
php artisan media:backfill-optimization-status
Options:
--chunk=200— number of rows to update per batch. Default is 200.
The command is idempotent — it only touches rows whose optimization_status is still null, and each write is guarded by an atomic whereNull('optimization_status') check so a concurrent MediaProcessingService run cannot be clobbered.
Related
- Working with Models — general
Mediamodel reference. - Customization — pipeline hooks that fire around processing (
ap.mediaLibrary.beforeProcess,ap.mediaLibrary.thumbnailsGenerated).

