id(); // Identity is (disk, path): the file is the record. Two models // pointing at the same upload share one row and one set of // derived versions. $table->string('disk', 40); $table->string('path', 500); $table->string('mime', 100)->nullable(); $table->unsignedBigInteger('size')->nullable(); // The pair that stops the page reflowing as images load. $table->unsignedInteger('width')->nullable(); $table->unsignedInteger('height')->nullable(); // Tiny blurred data URI, inlined by the frontend while the real // image loads. $table->text('placeholder')->nullable(); // [{ "format": "webp", "width": 800, "path": "...", "size": 1234 }] $table->jsonb('variants')->nullable(); // Null means "not processed yet". The API serves the image anyway, // just without the extras, so an unprocessed upload degrades into // a plain rather than a hole in the page. $table->timestamp('processed_at')->nullable(); $table->text('error')->nullable(); $table->timestamps(); $table->unique(['disk', 'path']); $table->index('processed_at'); }); // Alt text belongs to the record showing the image, not to the file: // the same photo can need a different description in a project page // than in a listing, and only the context knows which. Schema::table('pages', function (Blueprint $table) { $table->jsonb('featured_image_alt')->nullable()->after('featured_image'); }); Schema::table('projects', function (Blueprint $table) { $table->jsonb('cover_image_alt')->nullable()->after('cover_image'); }); } public function down(): void { Schema::table('projects', fn (Blueprint $table) => $table->dropColumn('cover_image_alt')); Schema::table('pages', fn (Blueprint $table) => $table->dropColumn('featured_image_alt')); Schema::dropIfExists('media'); } };