kenneth@vps~/projects/collectorwwiicat README.md
CollectorWWII
Een full-stack Laravel 11-applicatie die acht soorten WOII-verzamelingen beheert — boeken, items, tijdschriften, kranten, bankbiljetten, munten, postkaarten en postzegels. Volledig zelf gebouwd en uitgebaat, van het gedeelde polymorfe mediamodel tot de zelfgehoste productiestack.
kenneth@vps~/projects/collectorwwiicat docs/architecture.txt
01 / productiestack
De app draait op een zelfbeheerde Hetzner-VPS — elke laag, van DNS tot objectopslag, is rechtstreeks zelf geconfigureerd en wordt zelf onderhouden, met media off-server geserveerd via B2 en de Cloudflare CDN.
kenneth@vps~/projects/collectorwwiicat app/Models/MediaFile.php
02 / datamodel en verzamelingsontwerp
// 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]); }
- Eén
MediaFile-model, geïdentificeerd viaattachable_type/attachable_id, handelt afbeeldingen en documenten af voor alle acht verzameltypes — geen mediatabellen per type. - Wordt de hoofdafbeelding verwijderd, dan wordt automatisch de volgende afbeelding op
sort_ordergepromoveerd.
kenneth@vps~/projects/collectorwwiicat app/Http/Controllers/Admin/BookController.php
03 / transactionele uploads met S3-rollback
Een entry met afbeeldingen of pdf's aanmaken laat het
databaserecord, de auteursynchronisatie en elke B2-upload binnen
één DB::transaction() lopen. Faalt er iets halverwege,
dan worden de al geüploade bestanden verwijderd via een
cleanup-lijst.
// 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; }
Bestanden worden opgeslagen onder {type}/{id}/{uuid}.{ext}.
Het uploadtempo is beperkt tot 10 creates per 60 seconden per
gebruiker via Laravels RateLimiter.
kenneth@vps~/projects/collectorwwiicat app/Http/Controllers/Public/BookController.php
04 / dynamisch filteren en sorteren
Filters voor onderwerp, reeks, covertype en verkoopstatus worden
toegepast als conditionele where()-clausules, enkel
toegevoegd wanneer de bijbehorende requestparameter aanwezig is.
Sorteeropties zijn gewhitelist via een switch-blok —
kolomnamen van gebruikers bereiken de query nooit rechtstreeks.
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
Boeken sorteren op auteur gebeurt via een gecorreleerde subquery in
plaats van een join, wat de dubbele rijen vermijdt die een
many-to-many LEFT JOIN zou opleveren; de secundaire
orderBy('title') houdt de paginatie stabiel.
kenneth@vps~/projects/collectorwwiiphp artisan route:list --path=admin
05 / autorisatie — policies en rolcontrole
// 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'); }
- De admin-routegroep wordt geregistreerd in
AppServiceProvider, krijgt de prefixadmin/en zit achter Laravels ingebouwdeauth-middleware. - Eén rolcontrole (
role_id === 1) bepaalt de admin-toegang voor elk onderdeel. - Resources gebruiken
authorizeResource()in de constructor om policychecks automatisch op alle CRUD-acties toe te passen. - Niet-CRUD-onderdelen (blog, kaart, lookup-tabellen) delen één
AdminOnlyPolicy.
kenneth@vps~/projects/collectorwwiils features/
06 / vermeldenswaardige implementatiedetails
- isbn-prefill
Het aanmaken van boeken aanvaardt
?isbn=; de controller roept bij het laden de Google Books API aan en vult titel, auteurs, uitgever, jaar en aantal pagina's vooraf in, met nette fallback als de API onbeschikbaar is. - feature-flags
Alle acht verzameltypes zijn individueel in of uit te schakelen via
config/collector.php→enabled_sections; routes registreren dynamisch vanuit die config. - multilingual-blog
Een JSON-gebaseerde blog ondersteunt EN-, NL-, DE- en FR-content — taal ingesteld via de sessie met een
/change-language/{language}-route, geen databasetabel nodig. - polymorphic-media-routes
Eén
MediaFileControllermet een gewhiteliste{type}-routeparameter regelt upload, verwijderen, hoofdafbeelding-promotie en herordenen voor alle acht types. - soft-deletes
Boeken en items gebruiken Laravels
SoftDeletes-trait; definitief verwijderen met B2-opruiming is een expliciete admin-actie. - hierarchical-lookups
Onderwerpen, categorieën, herkomsten, locaties en organisaties ondersteunen ouder-kind-boomstructuren via een
flatTree()-scope, plat en ingesprongen teruggegeven voor select-dropdowns.
kenneth@vps~/projects/collectorwwiicat POSTMORTEM.md
07 / uitdagingen & conclusies
- Het mediamodel over acht types. Polymorfe relaties vermeden mediatabellen per type, maar vroegen eager loading en scoped relaties om N+1-queries op lijstpagina's te voorkomen.
- Transactionele uploads met externe opslag. Een DB-transactie rolt terug bij falen, maar B2-bestanden kunnen dat niet; het cleanup-arraypatroon lost dat op zonder job queue of saga.
- Auteursortering zonder duplicaten. Een gecorreleerde subquery die enkel de eerste auteursnaam teruggeeft vervangt een join, zonder
DISTINCT-omweg. - Schalen naar acht bijna identieke types. De polymorfe mediacontroller en feature-flag-config voorkomen dat de codebase 8× groeit bij elk nieuw verzameltype.
- Wat dit aantoont — gedeelde abstracties boven duplicatie, uploadcode die veilig is bij gedeeltelijke fouten, subquery- versus join-inzicht, en end-to-end eigenaarschap van deploy, DNS, proxy, SSL, opslag en back-ups.
✓ exit 0