DevOps & Programming

By Edward Mooney

Stop Teaching OOP With Cars: Let's Build Something Real in Laravel

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.

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.


Step 1: The UML — thinking before typing

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:

  • A gallery contains many media items.
  • A media item is either an image or a video. The grid doesn't care which.
  • Every media item needs a thumbnail — but images and videos generate them completely differently (resize vs. extract a frame).
  • Files get stored on a "disk" — local in development, S3 in production. The rest of the app shouldn't know or care.
  • Users can comment on any media item, image or video alike.

Sketch that, and you get something like this:

classDiagram class Gallery { +string title +string slug +addItem(MediaItem) +items() MediaItem[] } class MediaItem { <<abstract>> +string title +string filePath +string disk +url() string +thumbnailUrl() string +type() string } class Image { +int width +int height } class Video { +int durationSeconds +string codec } class ThumbnailGenerator { <<interface>> +generate(MediaItem) string } class ImageThumbnailGenerator { +generate(MediaItem) string } class VideoThumbnailGenerator { +generate(MediaItem) string } class Comment { +string body +User author } Gallery "1" --> "*" MediaItem : contains MediaItem <|-- Image MediaItem <|-- Video ThumbnailGenerator <|.. ImageThumbnailGenerator ThumbnailGenerator <|.. VideoThumbnailGenerator MediaItem "1" --> "*" Comment : has

Read the arrows and you can already see every OOP concept, doing an actual job:

  • CompositionGallery → MediaItem and MediaItem → Comment are has-a arrows.
  • InheritanceImage and Video extend MediaItem, shallow: one level, two children.
  • AbstractionThumbnailGenerator is an interface: classes promise a capability rather than share a parent.
  • Polymorphism — the grid calls $item->thumbnailUrl() on a mixed list and gets correct behavior from each.

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.


Step 2: From UML to objects — the decisions the diagram forces

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.

"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.

"Comments on any media item" → Laravel ships with polymorphic relationships (morphMany / morphTo) for exactly this.

Now we type.


Step 3: The code

The base model — encapsulation with a real reason

<?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:

  1. $fillable = ['title'] means a malicious request containing disk=../../etc bounces off.
  2. url() is behavior, not data. When you move buckets, you change nothing outside this class.

The children — inheritance that earns its keep

<?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.

The interface — abstraction where the heavy lifting lives

<?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.

Composition + dependency injection — the coordinator

<?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!");
}

The payoff — testing without touching S3 or ffmpeg

<?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.


Build it yourself: the command cheat sheet

# 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

What the Vehicle tutorials should have said

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.

Next time you see a tutorial explain inheritance with a Motorcycle, ask it: "cool — now how do I test it without a garage?"