By Edward Mooney
Every OOP tutorial ever written opens the same way. There's a Vehicle class. It has a speed property and a move() method. Then — brace yourself for the twist — a Car extends Vehicle, and a Motorcycle also extends Vehicle, but the motorcycle has wheels = 2 and the car has wheels = 4. Sometimes, if the author is feeling spicy, a Truck shows up with a cargoCapacity.
Vehicle
speed
move()
Car
Motorcycle
wheels = 2
wheels = 4
Truck
cargoCapacity
And then the tutorial ends. You close the tab knowing that a motorcycle has two wheels — something you presumably knew before — and having absolutely no idea why any of this matters, when you'd write a class, what problem inheritance solves, or why your actual codebase is a pile of 900-line controllers.
Here's the thing nobody says out loud: you will never ship a Vehicle class. Nobody is paying you to model the difference between a car and a motorcycle. The reason those examples teach nothing is that they demonstrate the syntax of OOP using objects that have no behavior, no rules, no persistence, no edge cases — no job.
So let's do it properly. We're going to design and build a media gallery — the kind of feature that shows up in basically every real product: users upload images and videos, we generate thumbnails, store files somewhere (local disk today, S3 tomorrow), and display everything in one unified grid. We'll go the way real design work goes: UML → objects → Laravel code. And every OOP concept will show up because the project demands it, not because a curriculum said so.
UML gets a bad reputation because enterprises turned it into a bureaucratic art form. Used lightly, it's just a sketch: what are the things, what do they know, what can they do, and how do they relate? Ten minutes of this saves you two days of refactoring.
Our requirements in plain English:
Sketch that, and you get something like this:
Read the arrows and you can already see every OOP concept, doing an actual job:
Gallery → MediaItem
MediaItem → Comment
Image
Video
MediaItem
ThumbnailGenerator
$item->thumbnailUrl()
Notice what's not in the diagram: no StorageManager, no S3 class. That's deliberate — storage is a detail we'll depend on through Laravel's filesystem contract, which is abstraction we get for free.
StorageManager
Before code, translate each design decision into a concrete question:
"The grid doesn't care if it's an image or a video" → we need one type the grid can hold. In Laravel-land, we'll use single table inheritance: one media_items table, a type column, and Eloquent models Image and Video extending a base MediaItem.
media_items
type
"Thumbnails are generated completely differently per type" → this screams for polymorphism. Behavior that belongs to the data goes on the model. Behavior that involves heavy machinery (ffmpeg, image libraries) goes in generator classes behind an interface.
"Local disk today, S3 tomorrow" → never call S3 directly. Code against Illuminate\Contracts\Filesystem\Filesystem, configure the disk in one place, done.
Illuminate\Contracts\Filesystem\Filesystem
"Comments on any media item" → Laravel ships with polymorphic relationships (morphMany / morphTo) for exactly this.
morphMany
morphTo
Now we type.
<?php namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Facades\Storage; abstract class MediaItem extends Model { protected $table = 'media_items'; protected $fillable = ['title']; protected $casts = [ 'meta' => 'array', ]; abstract public function type(): string; abstract public function thumbnailPath(): string; public function url(): string { return Storage::disk($this->disk)->url($this->file_path); } public function thumbnailUrl(): string { return Storage::disk($this->disk)->url($this->thumbnailPath()); } public function comments(): MorphMany { return $this->morphMany(Comment::class, 'commentable'); } }
Two encapsulation moves here, both preventing real incidents:
$fillable = ['title']
disk=../../etc
url()
<?php namespace App\Models; class Image extends MediaItem { public function type(): string { return 'image'; } public function thumbnailPath(): string { return 'thumbnails/' . basename($this->file_path); } public function dimensions(): string { return "{$this->meta['width']}×{$this->meta['height']}"; } } class Video extends MediaItem { public function type(): string { return 'video'; } public function thumbnailPath(): string { return 'thumbnails/' . pathinfo($this->file_path, PATHINFO_FILENAME) . '.jpg'; } public function duration(): string { return gmdate('i:s', $this->meta['duration_seconds']); } }
// GalleryController $items = $gallery->items; // mixed collection of Image and Video models
{{-- galleries/show.blade.php --}} <div class="grid"> @foreach ($items as $item) <a href="{{ $item->url() }}" class="tile tile--{{ $item->type() }}"> <img src="{{ $item->thumbnailUrl() }}" alt="{{ $item->title }}"> @if ($item->type() === 'video') <span class="badge">{{ $item->duration() }}</span> @endif </a> @endforeach </div>
That $item->thumbnailUrl() line is polymorphism doing its job: one method call on a mixed pile of objects, each answering correctly.
<?php namespace App\Contracts; use App\Models\MediaItem; interface ThumbnailGenerator { public function generate(MediaItem $item): string; public function supports(MediaItem $item): bool; }
<?php namespace App\Services\Thumbnails; use App\Contracts\ThumbnailGenerator; use App\Models\{MediaItem, Image}; use Illuminate\Contracts\Filesystem\Factory as Storage; use Intervention\Image\ImageManager; class ImageThumbnailGenerator implements ThumbnailGenerator { public function __construct( private ImageManager $images, private Storage $storage, ) {} public function supports(MediaItem $item): bool { return $item instanceof Image; } public function generate(MediaItem $item): string { $disk = $this->storage->disk($item->disk); $thumb = $this->images->read($disk->get($item->file_path))->scaleDown(width: 480); $path = $item->thumbnailPath(); $disk->put($path, $thumb->toJpeg(quality: 80)); return $path; } }
<?php namespace App\Services\Thumbnails; use App\Contracts\ThumbnailGenerator; use App\Models\{MediaItem, Video}; use ProtoneMedia\LaravelFFMpeg\Support\FFMpeg; class VideoThumbnailGenerator implements ThumbnailGenerator { public function supports(MediaItem $item): bool { return $item instanceof Video; } public function generate(MediaItem $item): string { $path = $item->thumbnailPath(); FFMpeg::fromDisk($item->disk) ->open($item->file_path) ->getFrameFromSeconds(1) ->export()->toDisk($item->disk)->save($path); return $path; } }
Both generators talk to storage through Laravel's filesystem contract — swap FILESYSTEM_DISK=s3 in .env and neither class changes a character.
FILESYSTEM_DISK=s3
.env
<?php namespace App\Services; use App\Contracts\ThumbnailGenerator; use App\Models\{Gallery, MediaItem, Image, Video}; use Illuminate\Http\UploadedFile; use Illuminate\Contracts\Filesystem\Factory as Storage; class MediaUploader { /** @param ThumbnailGenerator[] $generators */ public function __construct( private Storage $storage, private iterable $generators, ) {} public function upload(Gallery $gallery, UploadedFile $file, string $title): MediaItem { $model = str_starts_with($file->getMimeType(), 'video/') ? new Video() : new Image(); $model->title = $title; $model->disk = config('filesystems.default'); $model->file_path = $file->store('originals', $model->disk); $gallery->items()->save($model); foreach ($this->generators as $generator) { if ($generator->supports($model)) { $generator->generate($model); break; } } return $model; } }
// app/Providers/AppServiceProvider.php public function register(): void { $this->app->when(MediaUploader::class) ->needs('$generators') ->give(fn ($app) => [ $app->make(ImageThumbnailGenerator::class), $app->make(VideoThumbnailGenerator::class), ]); }
// app/Http/Controllers/MediaController.php public function store(StoreMediaRequest $request, Gallery $gallery, MediaUploader $uploader) { $item = $uploader->upload($gallery, $request->file('media'), $request->input('title')); return redirect()->route('galleries.show', $gallery)->with('status', "{$item->type()} uploaded!"); }
<?php use App\Contracts\ThumbnailGenerator; use App\Models\{Gallery, Video, MediaItem}; use App\Services\MediaUploader; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; it('stores an upload and generates a thumbnail', function () { Storage::fake('local'); $fakeGenerator = new class implements ThumbnailGenerator { public array $generated = []; public function supports(MediaItem $item): bool { return true; } public function generate(MediaItem $item): string { $this->generated[] = $item->id; return 'thumbnails/fake.jpg'; } }; $uploader = new MediaUploader(Storage::getFacadeRoot(), [$fakeGenerator]); $gallery = Gallery::factory()->create(); $item = $uploader->upload($gallery, UploadedFile::fake()->create('clip.mp4', 2048, 'video/mp4'), 'My clip'); expect($item)->toBeInstanceOf(Video::class); expect($fakeGenerator->generated)->toContain($item->id); Storage::disk('local')->assertExists($item->file_path); });
No S3 credentials. No ffmpeg on CI. Milliseconds per test. Testability is the receipt you get for doing OOP right.
# 0. Fresh project (skip if adding to an existing app) composer create-project laravel/laravel media-gallery cd media-gallery # 1. Packages composer require intervention/image composer require pbmedia/laravel-ffmpeg composer require tightenco/parental # 2. Models + migrations php artisan make:model Gallery -m php artisan make:model MediaItem -m php artisan make:model Image php artisan make:model Video php artisan make:model Comment -m # 3. Contracts + services php artisan make:interface Contracts/ThumbnailGenerator php artisan make:class Services/Thumbnails/ImageThumbnailGenerator php artisan make:class Services/Thumbnails/VideoThumbnailGenerator php artisan make:class Services/MediaUploader # 4. HTTP layer php artisan make:controller MediaController php artisan make:controller GalleryController php artisan make:request StoreMediaRequest # 5. Tests php artisan make:factory GalleryFactory --model=Gallery php artisan make:test MediaUploaderTest --pest # 6. Run php artisan storage:link php artisan migrate php artisan serve
Strip the taxonomy cosplay away and OOP is a handful of load-bearing ideas:
A class keeps data and the rules protecting that data in one place. Inheritance is for the rare case where types must be genuinely interchangeable at one call site — keep it one level deep. An interface names a capability so callers depend on the concept. Polymorphism deletes your if/else chains. Composition + dependency injection assembles small single-purpose objects into features. And testing with fakes is the proof the design works.
if/else
Next time you see a tutorial explain inheritance with a Motorcycle, ask it: "cool — now how do I test it without a garage?"