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.
kenneth@vps~/projects/collectorwwiicat docs/architecture.txt
01 / production deployment stack
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
MediaFilemodel, keyed byattachable_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_orderis 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, prefixedadmin/, and gated behind Laravel's built-inauthmiddleware. - 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.php→enabled_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
MediaFileControllerwith 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
SoftDeletestrait; 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
DISTINCTworkaround 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