DevOps & Programming

By Edward Mooney

Stop teaching OOP with cars: let's build a photo gallery instead

Almost every lesson about object-oriented programming starts the same way. There's a Vehicle. Then a Car that extends Vehicle. Then a Motorcycle that also extends Vehicle, except — plot twist — it has two wheels instead of four. The end.

And you sit there thinking: okay… but I already knew motorcycles have two wheels. What did I actually learn?

Nothing. That's the problem. Nobody has ever been paid to write a Vehicle class. Those examples show you the words of OOP without ever showing you the reason for it. It's like teaching someone to cook by only ever letting them read the labels on spice jars.

So let's cook a real meal. We're going to build something that shows up in almost every real app: a media gallery. People upload photos and videos, we make little thumbnail pictures for each one, we save the files somewhere, and we show everything in one nice grid. Simple to describe — and it just so happens to need every single OOP idea to build well. We'll do it the way real builders do: draw it first, decide things second, code it third. The code is PHP with Laravel, but the ideas work in any language.

Step 1: The UML (Unified Modeling Language)

Before writing any code, smart builders make a sketch. In programming this sketch has a fancy name — UML — but don't let that scare you. It's just a drawing that answers three questions: what are the things, what can each thing do, and how are they connected? Ten minutes of drawing saves you days of un-tangling code later.

Here's our whole gallery, drawn as a map:

Laravel media gallery class connections Gallery is composed of MediaItem; Image and Video extend MediaItem; two generators implement the ThumbnailGenerator interface; MediaUploader composes the generators and the filesystem contract and is injected into the controller. Gallery hasMany items MediaItem abstract, guards state Image resize thumb Video frame thumb Comment morphTo commentable ThumbnailGenerator interface: generate() ImageThumb Intervention VideoThumb ffmpeg MediaUploader composes the pieces Filesystem contract local or S3 disk Controller type-hints it has many extends belongs to implements injects generators uses disk creates Solid arrows: composition and inheritance. Dashed arrows: implements an interface.

Let's read the arrows like sentences, because each kind of arrow means something different:

The "has many" arrow from Gallery to MediaItem says: a gallery is a container. It's not a special kind of photo — it's a box that holds photos and videos. Programmers call this composition, and honest truth: it's the arrow you'll draw the most in your whole career. Most things in software are boxes holding other things.

The "extends" arrows from Image and Video up to MediaItem say: these two are children of the same parent recipe. They both promise the same basics — "I have a title, I have a file, I can show you a thumbnail" — but each one keeps its own secrets about how. This is inheritance, and notice something: we use it exactly once, one level deep. No Motorcycle-extends-TwoWheeler-extends-Vehicle-extends-Machine family trees. Real code keeps this rare and shallow.

The dashed "implements" arrows are different. They don't say "I'm your child" — they say "I promise I can do this job." The ThumbnailGenerator box is a job posting: "wanted — anything that can make thumbnails." Two workers apply: one that shrinks images, one that grabs a frame out of a video. This is called an interface, and it's a promise, not a family.

And down at the bottom, MediaUploader is the team captain. It doesn't make thumbnails itself or save files itself — it just knows which teammate to hand each job to. Hold that thought; the captain is the best part.

Step 2: UML to Objects

Before coding, the drawing forces us to make a few decisions. The biggest one: photos and videos are different things, but the grid on our page doesn't want to care. The grid just wants to say "give me the next thing, I'll show its little picture." How do we store two kinds of things so they act like one?

Think of your phone's camera roll. Photos and videos sit mixed together in one grid, in order. But tap a video and it knows it's a video — it plays, it shows its length. Tap a photo and it just shows the photo. One shelf on the outside, different behavior on the inside.

We copy that exactly. In the database we make one table called media_items — that's the shelf. Every row gets a little sticker, a column called type, that says what it is:

type title extra info
image Beach sunset how wide, how tall
video Surf clip how long it plays
image Boardwalk how wide, how tall

When Laravel picks a row off the shelf, it reads the sticker. Sticker says video? You get a Video object. Sticker says image? You get an Image object. This trick has another name — (STI) single table inheritance — but it's just one shelf, with stickers.

Two more decisions the drawing forces. Making thumbnails is heavy work — shrinking pictures needs an image tool, grabbing video frames needs a program called ffmpeg. That requires worker classes behind the scenes, so our models stay light. And file storage? We'll save to the computer's own disk while building, and to Amazon S3 when the site goes live.

Okay. Now we type.

The Code: a box that holds things

The simplest class in the whole system, on purpose. A gallery has media items. It doesn't shrink them, save them, or play them — it just holds them:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;

class Gallery extends Model
{
    protected $fillable = ['title'];

    protected static function booted(): void
    {
        static::creating(function (Gallery $gallery) {
            $gallery->slug = Str::slug($gallery->title);
        });
    }

    public function items(): HasMany
    {
        // Hands back a MIXED pile of Image and Video objects.
        // Laravel reads each row's "type" sticker and builds
        // the right one automatically.
        return $this->hasMany(MediaItem::class);
    }

    public function getRouteKeyName(): string
    {
        return 'slug';   // pretty URLs: /galleries/summer-trip
    }
}

That items() method IS the "has many" arrow from our drawing, turned into code. One sneaky-smart detail: the web address name (the slug) writes itself when a gallery is created, and it's not in the $fillable list. That means nobody visiting the site can sneak in their own slug through a form. The gallery guards its own name tag.

The MediaItem: a class that protects itself

Here's the parent recipe both children will follow. Read the comments — this class is basically a vending machine:

<?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';

    // Only "title" can be filled in from a web form.
    // Nobody gets to set file_path or disk from outside. Ever.
    protected $fillable = ['title'];

    protected $casts = [
        'meta' => 'array',
    ];

    // Every child MUST answer these two questions.
    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');
    }
}

Why a vending machine? Because with a vending machine, you press buttons on the outside — you don't reach your arm inside and grab a snack off the spiral. This class works the same way. Want a file's web address? You ask: $item->url(). You don't glue strings together yourself in fourteen different template files. And the $fillable list means a sneaky visitor who sends file_path=../../secret-stuff in a form just bounces off the glass.

This idea — the object guards its own insides and only offers safe buttons — is called encapsulation. It's not about being secretive. It's about making sure there's exactly one place where the rules live, so the rules can't be broken by accident.

Image and Video: same parent, different talents

<?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']);
    }
}

Both children answer the parent's required questions, each in its own way. And now watch what that buys us in the page that shows the grid:

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

Look at $item->thumbnailUrl(). The grid asks every item the same question, and each item answers in its own way — images point to their shrunk copy, videos point to their captured frame. The grid never checks which is which. It's like a teacher asking a whole class "what's your favorite subject?" — one question, thirty different answers, and the teacher didn't need a different sentence for each kid.

That's polymorphism, and it's the idea that deletes ugly code. Without it, every page would be stuffed with "if it's a video do these 30 lines, else do these other 30 lines." With it? One line. And if we add audio files next year, we write one new class and this page barely changes.

The job posting: anyone who can make thumbnails, apply here

Actually making thumbnails is heavy lifting, so it doesn't belong inside our tidy model classes. Instead we write a job posting — an interface:

<?php

namespace App\Contracts;

use App\Models\MediaItem;

interface ThumbnailGenerator
{
    public function generate(MediaItem $item): string;

    public function supports(MediaItem $item): bool;
}

Read it like a help-wanted ad: "Must be able to generate a thumbnail. Must be able to say which items you support. We don't care who you are or how you do it." Two workers apply for the job:

class ImageThumbnailGenerator implements ThumbnailGenerator
{
    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;
    }
}

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;
    }
}

One shrinks pictures, one grabs the video frame at the one-second mark. Completely different insides, identical promise on the outside. And notice: both talk to storage through Laravel's magic closet, never directly to Amazon. When we flip from local disk to S3 later, neither of these classes changes a single letter.

The team captain: MediaUploader

Now the class that ties it together. The captain doesn't play every position — it passes the ball:

<?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;
    }
}

Here's the part worth staring at: the captain's teammates arrive through the front door — the constructor at the top. The captain never builds its own storage or its own generators. Someone hands them over. It's the difference between a chef who grows the vegetables, raises the chickens, AND cooks the meal… versus a chef who gets ingredients delivered and just cooks. Our captain just cooks.

This has a fancy name — dependency injection — but it's literally "hand me my tools instead of making me build them." And Laravel has a built-in helper called the service container whose whole job is doing the handing. You tell it once, in one place, which workers to deliver:

// 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),
        ]);
}

…and then your controller just asks for a captain, and one shows up fully equipped:

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: practicing with pretend pieces

Why did we bother with job postings and front-door deliveries? Here's why. Because the captain accepts any worker that fits the job posting, we can hand it a pretend worker when we test:

it('stores an upload and generates a thumbnail', function () {
    Storage::fake('local');    // a pretend closet — no real files touched

    $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;   // just take notes
            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);
});

It's like a fire drill. You don't set the school on fire to practice — you use a pretend alarm, and everyone still learns exactly where the exits are. This test runs in milliseconds, needs no Amazon account and no video software installed, and it proves the whole upload dance works. If your code is hard to test like this, that's almost always a sign the OOP design went wrong somewhere — some class is secretly building its own tools instead of accepting them at the front door.

The whole system at a glance

Here's the map again, now that every box is real code you've seen. Purple boxes are our data models, teal is the thumbnail job posting and its two workers, coral is the team captain, gray is plumbing Laravel gave us for free:

Laravel media gallery class connections Gallery is composed of MediaItem; Image and Video extend MediaItem; two generators implement the ThumbnailGenerator interface; MediaUploader composes the generators and the filesystem contract and is injected into the controller. Gallery hasMany items MediaItem abstract, guards state Image resize thumb Video frame thumb Comment morphTo commentable ThumbnailGenerator interface: generate() ImageThumb Intervention VideoThumb ffmpeg MediaUploader composes the pieces Filesystem contract local or S3 disk Controller type-hints it has many extends belongs to implements injects generators uses disk creates Solid arrows: composition and inheritance. Dashed arrows: implements an interface.

Every arrow is now a thing you can point to: "has many" is the Gallery's items() method, "extends" is our one careful use of inheritance, the dashed "implements" arrows are the job posting that made testing easy, and everything flowing into MediaUploader is the captain getting its team handed to it through the front door.

Commands Cheat Sheet

Everything above scaffolds out with a handful of terminal commands, in this order:

# 0. Fresh project (skip if adding to an existing app)
composer create-project laravel/laravel media-gallery
cd media-gallery

# 1. The packages doing the heavy lifting
composer require intervention/image           # image thumbnails
composer require pbmedia/laravel-ffmpeg       # video frames (needs ffmpeg installed)
composer require tightenco/parental           # the "sticker reading" helper (optional)

# 2. Models + migrations
php artisan make:model Gallery -m
php artisan make:model MediaItem -m            # the parent owns the table
php artisan make:model Image                   # no -m: children share the shelf
php artisan make:model Video                   # no -m: same
php artisan make:model Comment -m

# 3. Job posting + workers + captain (make:interface and make:class
#    need Laravel 11+; on older versions just create the files by hand)
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. Web layer
php artisan make:controller MediaController
php artisan make:controller GalleryController
php artisan make:request StoreMediaRequest

# 5. Testing gear
php artisan make:factory GalleryFactory --model=Gallery
php artisan make:test MediaUploaderTest --pest

# 6. Wire it up and run
php artisan storage:link
php artisan migrate
php artisan serve

Three small notes so nothing trips you up. The media_items migration needs the sticker column — $table->string('type') — plus title, file_path, disk, and a json('meta') column for the extra info. The comments migration gets its shape-shifting columns with one line: $table->morphs('commentable'). And when you're ready to move from your computer's disk to Amazon S3, the whole move is one package (composer require league/flysystem-aws-s3-v3) and one line in your .env file. Not one class changes — because everything talked to the magic closet, never to Amazon directly.

Takeaways

Here's the whole post in one breath. OOP is just a few simple habits: keep your data and its rules in one box, hide the insides, and only offer safe buttons. Make promises with interfaces instead of building family trees. Ask every object the same question and let each one answer its own way. Build big things out of small things, and hand each piece its tools instead of making it build them. Do that, and testing gets easy — which is how you know you did it right. Next time a tutorial explains inheritance with a Motorcycle, ask it one question: "cool — now how do I test it without a garage?" If it can't answer, close the tab and go build a gallery instead.