kenneth@vps: ~/projects/collectorwwii

kenneth@vps~/projects/collectorwwiicat README.md

CollectorWWII

A full-stack Laravel 11 application managing eight WWII collection types — books, items, magazines, newspapers, banknotes, coins, postcards, and stamps. Built and operated end-to-end, from the shared polymorphic media model to the self-hosted production stack.

stack
Laravel 11 · PHP 8.2 · MySQL · Redis · Tailwind CSS · Alpine.js
status
In production at collectorwwii.eu
source
github.com/KennethCLA/collectorwwii
hosting
Self-hosted Hetzner VPS — infrastructure write-up
CollectorWWII items overview
collectorwwii.eu/items — public items overview

kenneth@vps~/projects/collectorwwiicat docs/architecture.txt

01 / production deployment stack

requests CloudflareHetzner VPS (Ubuntu)Nginx + SSLPHP-FPM 8.2Laravel 11MySQL + Redis media uploadsBackblaze B2 (S3-compatible) → served via Cloudflare CDN

The app runs on a self-managed Hetzner VPS — every layer, from DNS to object storage, is configured and maintained directly, with media served off-server via B2 and the Cloudflare CDN.

kenneth@vps~/projects/collectorwwiicat app/Models/MediaFile.php

02 / data model and collection design

// MediaFile — single table for all collection types
class MediaFile extends Model
{
    public function attachable(): MorphTo
    {
        return $this->morphTo();
    }
}

// Book, Item, Coin, etc. all share this pattern
public function media(): MorphMany
{
    return $this->morphMany(MediaFile::class, 'attachable');
}
// After upload: enforce exactly 1 main image
$mainCount = $imagesQuery
    ->where('is_main', 1)->count();

if ($mainCount === 0) {
    // Promote first by sort_order
    $first = $imagesQuery
        ->orderBy('sort_order')->first();
    $first->update(['is_main' => 1]);
} elseif ($mainCount > 1) {
    // Keep newest main, clear the rest
    $keepId = $imagesQuery
        ->where('is_main', 1)
        ->orderBy('id', 'desc')
        ->value('id');
    $imagesQuery->where('is_main', 1)
        ->where('id', '!=', $keepId)
        ->update(['is_main' => 0]);
}
  • One MediaFile model, keyed by attachable_type / attachable_id, handles images and documents for all eight collection types — no per-type media tables.
  • On deletion, if the main image is removed, the next image by sort_order is automatically promoted.

kenneth@vps~/projects/collectorwwiicat app/Http/Controllers/Admin/BookController.php

03 / transactional uploads with S3 rollback

Creating an entry with images or PDFs runs the DB record, author sync, and every B2 upload inside one DB::transaction(). If anything fails mid-way, already-uploaded files are deleted from a cleanup list.

// Uploaded paths tracked for cleanup on failure
$uploadedForCleanup = [];

try {
    $book = DB::transaction(function () use (&$uploadedForCleanup) {
        $book = Book::create($data);

        foreach ($imageUploads as $uploaded) {
            $filename = Str::uuid().'.'.$uploaded->extension();
            $path = $uploaded->storeAs($folder, $filename, 'b2');
            $uploadedForCleanup[] = ['b2', $path]; // track it
            $book->media()->create([...]);
        }

        return $book;
    });
} catch (\Throwable $e) {
    // DB rolled back — clean up orphaned B2 files
    foreach ($uploadedForCleanup as [$disk, $path]) {
        Storage::disk($disk)->delete($path);
    }
    throw $e;
}

Files are stored under {type}/{id}/{uuid}.{ext}. Upload rate is limited to 10 creates per 60 seconds per user via Laravel's RateLimiter.

kenneth@vps~/projects/collectorwwiicat app/Http/Controllers/Public/BookController.php

04 / dynamic filtering and sorting

Filters for topic, series, cover type, and sale status are applied as conditional where() clauses, only added when the matching request parameter is present. Sort options are whitelisted via a switch block — no user-supplied column names reach the query.

if ($request->filled('topic')) {
    $query->where('topic_id', $request->input('topic'));
}
if ($request->filled('series')) {
    $query->where('series_id', $request->input('series'));
}
if ($request->filled('for_sale')) {
    $query->where('for_sale', $request->boolean('for_sale'));
}

// Author sort via correlated subquery — avoids join duplicates
$query->orderBy(
    Author::select('name')
        ->join('book_authors', 'authors.id',
              '=', 'book_authors.author_id')
        ->whereColumn('book_authors.book_id', 'books.id')
        ->orderBy('name')
        ->limit(1),
    $dir
)->orderBy('title'); // stable secondary sort

Sorting books by author uses a correlated subquery rather than a join, avoiding the duplicate-row problem a many-to-many LEFT JOIN would cause; the secondary orderBy('title') keeps pagination stable.

kenneth@vps~/projects/collectorwwiiphp artisan route:list --path=admin

05 / authorization — policies and role gating

// BookPolicy — all write actions require role_id 1
class BookPolicy
{
    public function viewAny(?User $user): bool
    {
        return true; // public read
    }

    public function create(User $user): bool
    {
        return $user->role_id === 1;
    }

    public function update(User $user, Book $book): bool
    {
        return $user->role_id === 1;
    }
}

// Admin controller constructor
public function __construct()
{
    $this->authorizeResource(Book::class, 'book');
}
  • The admin route group is registered in AppServiceProvider, prefixed admin/, and gated behind Laravel's built-in auth middleware.
  • A single role check (role_id === 1) determines admin access across every section.
  • Resources call authorizeResource() in the constructor to apply policy checks to all CRUD actions automatically.
  • Non-CRUD sections (blog, map, lookup tables) share a single AdminOnlyPolicy.

kenneth@vps~/projects/collectorwwiils features/

06 / notable implementation details

  • isbn-prefill Book creation accepts ?isbn=; the controller calls the Google Books API on page load and pre-fills title, authors, publisher, year, and page count, with graceful fallback if the API is unavailable.
  • feature-flags All eight collection types are individually togglable via config/collector.phpenabled_sections; routes register dynamically from that config.
  • multilingual-blog A JSON-backed blog supports EN, NL, DE, FR content — language set via session through a /change-language/{language} route, no database table required.
  • polymorphic-media-routes One MediaFileController with a whitelisted {type} route parameter handles upload, deletion, main-image promotion, and reordering for all eight types.
  • soft-deletes Books and items use Laravel's SoftDeletes trait; hard deletion with B2 file cleanup is available as an explicit admin action.
  • hierarchical-lookups Topics, categories, origins, locations, and organisations support parent-child trees via a flatTree() scope, returned flat and indented for select dropdowns.

kenneth@vps~/projects/collectorwwiicat POSTMORTEM.md

07 / challenges & takeaways

  • Media model across eight types — polymorphic relations avoided per-type tables but needed eager loading and scoped relations to keep listing pages free of N+1 queries.
  • Transactional uploads with external storage — a DB transaction rolls back on failure, but B2 files can't; the cleanup-array pattern solves it without a job queue or saga.
  • Author sort without duplicates — a correlated subquery returning only the first author name replaces a join, with no DISTINCT workaround needed.
  • Scaling to eight nearly-identical types — the polymorphic media controller and feature-flag config keep the codebase from growing 8× per new collection type.
  • What it demonstrates — shared abstractions over duplication, upload code safe under partial failure, subquery vs. join judgment, and end-to-end ownership of deploy, DNS, proxy, SSL, storage, and backups.

exit 0