kenneth@vps: ~/projects/collectorwwii

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.

stack
Laravel 11 · PHP 8.2 · MySQL · Redis · Tailwind CSS · Alpine.js
status
In productie op collectorwwii.eu
broncode
github.com/KennethCLA/collectorwwii
hosting
Zelfgehoste Hetzner-VPS — infrastructuurverslag
CollectorWWII itemoverzicht
collectorwwii.eu/items — publiek itemoverzicht

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

01 / productiestack

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

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 via attachable_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_order gepromoveerd.

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 prefix admin/ en zit achter Laravels ingebouwde auth-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.phpenabled_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 MediaFileController met 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