# PreRegister — Development Prompt & Architecture Specification > **Purpose of this document:** This is the master prompt and specification for building "PreRegister" — a Laravel application for festival ticket pre-registration. Feed this document to **Claude Code** or **Cursor** to scaffold, develop, and iterate on the application. Every section is written as an actionable instruction set. --- ## 0. Context & Role You are a **senior full-stack software architect and Laravel expert**. You write clean, production-ready PHP 8.2+ / Laravel 11 code. You follow PSR-12, use strict typing, and prefer explicit over clever. You build with security, scalability, and maintainability in mind — even for "simple" apps. When developing this application: - **Think before you code.** Read the full spec first. Plan the migration order, model relationships, and route structure before writing a single file. - **Work incrementally.** Commit logically grouped changes. Don't dump the entire app in one pass. - **Test as you go.** After each feature, verify it works. Run `php artisan migrate:fresh --seed` regularly. - **Ask clarifying questions** if a requirement is ambiguous — don't guess. --- ## 1. Project Overview **PreRegister** is a multi-tenant pre-registration system for festival ticket sales. Event organizers create branded landing pages where visitors can sign up for early/discounted ticket access. Subscriber data is stored locally and optionally synced to Mailwizz (email marketing platform). ### Core User Flows ``` ┌─────────────────────────────────────────────────────────┐ │ BACKEND (authenticated) │ │ │ │ Superadmin → manages Users │ │ User → creates/edits Pre-Registration Pages │ │ → configures Mailwizz integration │ │ → views/exports subscribers │ └─────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────┐ │ FRONTEND (public, unique URL per page) │ │ │ │ Guest → sees countdown / form / expired message │ │ → fills in form (name, email, phone) │ │ → gets thank-you confirmation │ │ → data stored in DB + optionally pushed to │ │ Mailwizz with tag/source tracking │ └─────────────────────────────────────────────────────────┘ ``` --- ## 2. Tech Stack & Environment | Layer | Technology | |---|---| | Language | PHP 8.2+ (strict types everywhere) | | Framework | Laravel 11.x | | Database | MySQL 8.x | | Frontend (backend UI) | Blade + Tailwind CSS 3.x + Alpine.js 3.x | | Frontend (public pages) | Blade + Tailwind CSS + Alpine.js (countdown, form validation) | | Auth | Laravel Breeze (Blade stack) | | File storage | Local disk (`storage/app/public`, symlinked) | | HTTP client | Laravel HTTP facade (for Mailwizz API) | | Queue | `database` driver (for async Mailwizz sync) | | Cache | `file` driver (default) | ### Development Environment ```bash # Prerequisites php >= 8.2 with extensions: mbstring, xml, curl, mysql, gd/imagick composer >= 2.7 node >= 20 (for Tailwind/Vite) mysql >= 8.0 # Bootstrap composer create-project laravel/laravel preregister cd preregister composer require laravel/breeze --dev php artisan breeze:install blade npm install ``` --- ## 3. Database Schema Design migrations in this exact order. Use `$table->id()` (unsigned big int) for all primary keys. Use UUIDs for public-facing identifiers (slugs/URLs). ### Migration 1: Users table modification Laravel ships with a `users` table. Add a `role` enum column. ```php // database/migrations/xxxx_add_role_to_users_table.php Schema::table('users', function (Blueprint $table) { $table->enum('role', ['superadmin', 'user'])->default('user')->after('email'); }); ``` > **Note:** "Guest" is not a database role — it's an unauthenticated visitor on the public frontend. ### Migration 2: Pre-Registration Pages ```php Schema::create('preregistration_pages', function (Blueprint $table) { $table->id(); $table->uuid('slug')->unique(); // used in public URL: /r/{slug} $table->foreignId('user_id')->constrained()->cascadeOnDelete(); $table->string('title'); // internal title (backend) $table->string('heading'); // public heading on the page $table->text('intro_text')->nullable(); // shown before start_date (with countdown) $table->text('thank_you_message')->nullable(); // shown after successful registration $table->text('expired_message')->nullable(); // shown after end_date $table->string('ticketshop_url')->nullable(); // link shown after expiration $table->dateTime('start_date'); $table->dateTime('end_date'); $table->boolean('phone_enabled')->default(false); // show phone field yes/no $table->string('background_image')->nullable(); // path in storage $table->string('logo_image')->nullable(); // path in storage $table->boolean('is_active')->default(true); $table->timestamps(); }); ``` ### Migration 3: Subscribers ```php Schema::create('subscribers', function (Blueprint $table) { $table->id(); $table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete(); $table->string('first_name'); $table->string('last_name'); $table->string('email'); $table->string('phone')->nullable(); $table->boolean('synced_to_mailwizz')->default(false); $table->timestamp('synced_at')->nullable(); $table->timestamps(); // Prevent duplicate emails per page $table->unique(['preregistration_page_id', 'email']); }); ``` ### Migration 4: Mailwizz Configuration ```php Schema::create('mailwizz_configs', function (Blueprint $table) { $table->id(); $table->foreignId('preregistration_page_id')->constrained()->cascadeOnDelete(); $table->text('api_key'); // encrypted at rest $table->string('list_uid'); // Mailwizz list UID $table->string('list_name')->nullable(); // display name for reference $table->string('field_email')->default('EMAIL'); // Mailwizz tag for email $table->string('field_first_name')->default('FNAME'); $table->string('field_last_name')->default('LNAME'); $table->string('field_phone')->nullable(); // e.g. 'PHONE' $table->string('tag_field')->nullable(); // e.g. 'TAGS' — the checkboxlist field tag $table->string('tag_value')->nullable(); // e.g. 'ezf2026-preregister' $table->timestamps(); }); ``` > **Encryption:** The `api_key` column MUST use Laravel's `encrypted` cast on the model. Never store API keys in plain text. --- ## 4. Models & Relationships ### `User` model ``` - hasMany PreregistrationPage - Has `role` attribute (superadmin | user) - Add `isSuperadmin()` and `isUser()` helper methods ``` ### `PreregistrationPage` model ``` - belongsTo User - hasMany Subscriber - hasOne MailwizzConfig - Uses UUID slug for public URL resolution (route model binding on 'slug') - Add accessors: isBeforeStart(), isActive(), isExpired() - Add scope: scopeActive() - Cast start_date and end_date to datetime ``` ### `Subscriber` model ``` - belongsTo PreregistrationPage ``` ### `MailwizzConfig` model ``` - belongsTo PreregistrationPage - Cast 'api_key' => 'encrypted' ``` --- ## 5. Authentication & Authorization ### Roles | Role | Access | |---|---| | `superadmin` | Full backend access. Can manage users (CRUD). Can see/edit all pages. | | `user` | Backend access. Can only manage own pages and see own subscribers. | | `guest` | Public frontend only. No authentication required. | ### Implementation 1. Install Laravel Breeze (Blade stack) for login/register scaffolding. 2. **Disable public registration** — only superadmin can create users. Remove the registration routes or gate them behind superadmin middleware. 3. Create a `CheckRole` middleware: ```php // app/Http/Middleware/CheckRole.php public function handle($request, Closure $next, string ...$roles): Response { if (! in_array($request->user()->role, $roles)) { abort(403); } return $next($request); } ``` 4. Register in `bootstrap/app.php` as alias `role`. 5. Apply to routes: ```php Route::middleware(['auth', 'role:superadmin'])->group(function () { // User management routes }); Route::middleware(['auth', 'role:superadmin,user'])->group(function () { // Pre-registration page management routes }); ``` 6. Add a **Policy** for `PreregistrationPage`: - `superadmin` can do everything. - `user` can only view/edit/delete own pages. --- ## 6. Route Structure ```php // ─── Public (no auth) ──────────────────────────────── Route::get('/r/{slug}', [PublicPageController::class, 'show'])->name('public.page'); Route::post('/r/{slug}/subscribe', [PublicPageController::class, 'subscribe'])->name('public.subscribe'); // ─── Backend (auth required) ───────────────────────── Route::middleware(['auth', 'verified'])->prefix('admin')->name('admin.')->group(function () { Route::get('/dashboard', DashboardController::class)->name('dashboard'); // Pre-registration pages (CRUD) Route::resource('pages', PageController::class); // Subscribers (nested under pages) Route::get('pages/{page}/subscribers', [SubscriberController::class, 'index'])->name('pages.subscribers.index'); Route::get('pages/{page}/subscribers/export', [SubscriberController::class, 'export'])->name('pages.subscribers.export'); // Mailwizz configuration (nested under pages) Route::get('pages/{page}/mailwizz', [MailwizzController::class, 'edit'])->name('pages.mailwizz.edit'); Route::put('pages/{page}/mailwizz', [MailwizzController::class, 'update'])->name('pages.mailwizz.update'); Route::delete('pages/{page}/mailwizz', [MailwizzController::class, 'destroy'])->name('pages.mailwizz.destroy'); // Mailwizz AJAX endpoints (for dynamic field loading) Route::post('mailwizz/lists', [MailwizzApiController::class, 'lists'])->name('mailwizz.lists'); Route::post('mailwizz/fields', [MailwizzApiController::class, 'fields'])->name('mailwizz.fields'); // User management (superadmin only) Route::middleware('role:superadmin')->group(function () { Route::resource('users', UserController::class)->except(['show']); }); }); ``` --- ## 7. Backend Features — Detailed Specifications ### 7.1 Dashboard Simple overview showing: - Total pages (own pages for `user`, all for `superadmin`) - Total subscribers across pages - Active pages (currently within start/end date window) - Quick links to create new page ### 7.2 Pre-Registration Page CRUD **Create/Edit form fields:** | Field | Type | Validation | |---|---|---| | Title | text | required, max:255 | | Heading | text | required, max:255 | | Intro Text | textarea (rich or plain) | nullable | | Thank You Message | textarea | nullable | | Expired Message | textarea | nullable | | Ticket Shop URL | url | nullable, valid URL | | Start Date | datetime-local | required, valid date | | End Date | datetime-local | required, after:start_date | | Phone Enabled | checkbox | boolean | | Background Image | file upload | nullable, image, max:5MB, jpg/png/webp | | Logo Image | file upload | nullable, image, max:2MB, jpg/png/webp/svg | | Active | checkbox | boolean | **On create:** - Auto-generate a UUID slug (use `Str::uuid()`) - Store uploaded images via `Storage::disk('public')->put(...)` - Display the full public URL to the user: `{APP_URL}/r/{slug}` **Index view:** - Table with: Title, Status (before start / active / expired), Start Date, End Date, Subscriber Count, Actions (edit, view subscribers, public URL copy button, delete) - Superadmin sees all pages; User sees only own pages ### 7.3 Subscribers View - Table: First Name, Last Name, Email, Phone (if enabled), Registered At, Synced to Mailwizz (yes/no icon) - Pagination (25 per page) - CSV export button - Search/filter by name or email ### 7.4 Mailwizz Configuration (per page) This is a **multi-step configuration wizard** inside the page edit area (can be a separate tab or section). Use Alpine.js for dynamic behavior. **Step-by-step flow:** 1. **Enter API Key** - Input field for the Mailwizz API key - Display instruction text: *"First, create a mailing list in Mailwizz with the required custom fields. Make sure to add a custom field of the type 'Checkbox List' with an available option value that will be used to track the source of this pre-registration."* - "Connect" button → validates the API key by fetching lists 2. **Select List** - On valid API key: make a server-side call to `GET https://www.mailwizz.nl/api/lists` - Header: `X-Api-Key: {api_key}` - Display available lists in a dropdown (show `general.name`, store `general.list_uid`) - "Load Fields" button → fetches fields for the selected list 3. **Map Fields** - Fetch fields from `GET https://www.mailwizz.nl/api/lists/{list_uid}/fields` - Header: `X-Api-Key: {api_key}` - Display mapping dropdowns: | Local Field | Mailwizz Field Filter | Map To | |---|---|---| | Email | Show fields where `type.identifier` is `text` AND (`tag` contains `EMAIL` or is standard email field) | Dropdown of matching fields (display `label`, store `tag`) | | First Name | Show fields where `type.identifier` is `text` | Dropdown | | Last Name | Show fields where `type.identifier` is `text` | Dropdown | | Phone | Show fields where `type.identifier` is `phonenumber` | Dropdown (only shown if phone_enabled) | | Tag / Source | Show fields where `type.identifier` is `checkboxlist` | Dropdown of checkboxlist fields | 4. **Select Tag Value** - After selecting the checkboxlist field, load its `options` object - Display available options as radio buttons or dropdown (show the value label, store the key) - Example: key = `ezf2026-preregister`, label = `Echt Zomer Feesten 2026 - Pré-register` 5. **Save Configuration** - Store all mappings in the `mailwizz_configs` table - Show success message with summary of the configuration **AJAX Controller (`MailwizzApiController`):** ```php // POST /admin/mailwizz/lists // Receives: api_key // Returns: list of Mailwizz lists public function lists(Request $request): JsonResponse { $request->validate(['api_key' => 'required|string']); $response = Http::withHeaders([ 'X-Api-Key' => $request->api_key, ])->get('https://www.mailwizz.nl/api/lists'); if ($response->failed()) { return response()->json(['error' => 'Invalid API key or connection failed'], 422); } return response()->json($response->json()); } // POST /admin/mailwizz/fields // Receives: api_key, list_uid // Returns: list fields for the given list ``` --- ## 8. Frontend (Public Pages) — Detailed Specifications ### 8.1 Page States The public page at `/r/{slug}` has three states based on current date/time: #### State 1: Before Start Date (Countdown) ``` ┌──────────────────────────────────────────┐ │ [Full-screen background image] │ │ │ │ [Logo] │ │ │ │ {heading} │ │ │ │ {intro_text} │ │ │ │ ┌──────────────────────────┐ │ │ │ DD : HH : MM : SS │ │ │ │ days hrs mins secs │ │ │ └──────────────────────────┘ │ │ │ └──────────────────────────────────────────┘ ``` - Use Alpine.js for a live countdown timer - When countdown reaches zero, auto-refresh to show the form (or use Alpine to reactively switch state) #### State 2: Active (Registration Form) ``` ┌──────────────────────────────────────────┐ │ [Full-screen background image] │ │ │ │ [Logo] │ │ │ │ {heading} │ │ │ │ ┌──────────────────────────┐ │ │ │ First Name [________] │ │ │ │ Last Name [________] │ │ │ │ Email [________] │ │ │ │ Phone* [________] │ *if on │ │ │ │ │ │ │ [ Register ] │ │ │ └──────────────────────────┘ │ │ │ └──────────────────────────────────────────┘ ``` - Client-side validation with Alpine.js - Server-side validation in controller - On success: show `{thank_you_message}` (replace the form, no page reload — use Alpine) - On duplicate email error: show a friendly message ("You are already registered for this event") #### State 3: Expired ``` ┌──────────────────────────────────────────┐ │ [Full-screen background image] │ │ │ │ [Logo] │ │ │ │ {heading} │ │ │ │ {expired_message} │ │ │ │ [Visit Ticket Shop →] │ (if ticketshop_url set) │ │ └──────────────────────────────────────────┘ ``` ### 8.2 Styling Requirements - **Background image:** Full-screen cover, centered, no-repeat. Use CSS `background-size: cover; background-position: center;` on the `
` or a wrapper div. - **Overlay:** Add a semi-transparent dark overlay (e.g., `bg-black/50`) so text is readable on any image. - **Content card:** Center a card/container (max-width ~500px) with slight backdrop blur and padding. - **Logo:** Displayed above the heading, max-height ~80px, centered. - **Typography:** White text, clean sans-serif. Heading large (text-3xl), body text readable. - **Mobile responsive:** The form and all states must work perfectly on mobile. ### 8.3 Form Submission Logic ```php // PublicPageController@subscribe public function subscribe(Request $request, string $slug) { $page = PreregistrationPage::where('slug', $slug) ->where('is_active', true) ->firstOrFail(); // Verify page is in active window abort_if(now()->lt($page->start_date) || now()->gt($page->end_date), 403); $validated = $request->validate([ 'first_name' => 'required|string|max:255', 'last_name' => 'required|string|max:255', 'email' => 'required|email|max:255', 'phone' => $page->phone_enabled ? 'required|string|max:20' : 'nullable', ]); // Check duplicate $exists = $page->subscribers()->where('email', $validated['email'])->exists(); if ($exists) { return response()->json([ 'success' => false, 'message' => 'This email address is already registered.', ], 422); } // Store subscriber $subscriber = $page->subscribers()->create($validated); // Dispatch Mailwizz sync job (if configured) if ($page->mailwizzConfig) { SyncSubscriberToMailwizz::dispatch($subscriber); } return response()->json([ 'success' => true, 'message' => $page->thank_you_message ?? 'Thank you for registering!', ]); } ``` --- ## 9. Mailwizz Integration — Subscriber Sync Logic > **CRITICAL:** This is the most complex part of the app. Implement as a queued job. ### Job: `SyncSubscriberToMailwizz` ``` App\Jobs\SyncSubscriberToMailwizz - Receives: Subscriber $subscriber - Queue: 'mailwizz' - Retries: 3, backoff: [10, 30, 60] ``` ### Sync Algorithm ``` 1. Load the subscriber + related page + mailwizz config 2. Decrypt the API key from mailwizz_configs 3. SEARCH for existing subscriber in Mailwizz: GET https://www.mailwizz.nl/api/lists/{list_uid}/subscribers/search-by-email Query: EMAIL={subscriber.email} Header: X-Api-Key: {api_key} 4. IF subscriber does NOT exist (status: "error"): → CREATE subscriber POST https://www.mailwizz.nl/api/lists/{list_uid}/subscribers Body (form-data): {email_field_tag} = subscriber.email {fname_field_tag} = subscriber.first_name {lname_field_tag} = subscriber.last_name {phone_field_tag} = subscriber.phone (if mapped) {tag_field_tag} = [configured_tag_value] (send as array) 5. IF subscriber DOES exist (status: "success"): → FETCH full subscriber data GET https://www.mailwizz.nl/api/lists/{list_uid}/subscribers/{subscriber_uid} → Parse existing tag value from the tag field (comma-separated string) → Append configured_tag_value if not already present → Example: existing = "tag1,tag2" → append → "tag1,tag2,ezf2026-preregister" → UPDATE subscriber PUT https://www.mailwizz.nl/api/lists/{list_uid}/subscribers/{subscriber_uid} Body (form-data): {email_field_tag} = subscriber.email {fname_field_tag} = subscriber.first_name {lname_field_tag} = subscriber.last_name {phone_field_tag} = subscriber.phone (if mapped) {tag_field_tag} = [merged_tag_values_as_array] 6. On success: update subscriber record $subscriber->update(['synced_to_mailwizz' => true, 'synced_at' => now()]); 7. On failure: log the error, let the job retry ``` ### Important Mailwizz API Notes - **Base URL:** `https://www.mailwizz.nl/api` - **Auth:** Header `X-Api-Key: {key}` - **Data format:** Send as `form-data` (use `->asForm()` with Laravel HTTP client) - **Checkboxlist values** are stored as comma-separated strings in Mailwizz. When updating, you must: 1. Load existing value 2. Split by comma 3. Add the new value if not present 4. Send back as an **array** in the form data (Mailwizz expects array input for checkboxlist, it stores as CSV internally) - **Typo in docs:** The update endpoint in the Mailwizz docs has a typo (`/llists/` instead of `/lists/`). Use the correct URL: `https://www.mailwizz.nl/api/lists/{list_uid}/subscribers/{subscriber_uid}` ### Service Class Create a dedicated `App\Services\MailwizzService` class to encapsulate all API communication: ```php class MailwizzService { public function __construct( private string $apiKey, private string $baseUrl = 'https://www.mailwizz.nl/api' ) {} public function getLists(): array public function getListFields(string $listUid): array public function searchSubscriber(string $listUid, string $email): ?array public function getSubscriber(string $listUid, string $subscriberUid): ?array public function createSubscriber(string $listUid, array $data): array public function updateSubscriber(string $listUid, string $subscriberUid, array $data): array } ``` Each method should: - Use `Http::withHeaders(['X-Api-Key' => $this->apiKey])->asForm()` - Throw descriptive exceptions on failure - Log all API interactions at `debug` level --- ## 10. Security Checklist - [ ] **CSRF protection** on all forms (Blade `@csrf` directive) - [ ] **API key encryption** using Laravel's `encrypted` cast — never store in plain text - [ ] **File upload validation** — restrict to image types, enforce max size - [ ] **Authorization policies** — users can only access own resources - [ ] **Rate limiting** on public subscription endpoint (prevent spam): `throttle:10,1` (10 per minute) - [ ] **Input sanitization** — XSS protection on all user-provided content displayed in Blade - [ ] **Slug unpredictability** — UUID slugs prevent enumeration - [ ] **SQL injection** — use Eloquent/query builder only, never raw queries with user input - [ ] **Mass assignment protection** — define `$fillable` on every model - [ ] **HTTPS only** — set `APP_URL` with `https://`, enable `secure` cookies in production --- ## 11. File & Folder Structure ``` app/ ├── Http/ │ ├── Controllers/ │ │ ├── Admin/ │ │ │ ├── DashboardController.php │ │ │ ├── PageController.php │ │ │ ├── SubscriberController.php │ │ │ ├── MailwizzController.php │ │ │ ├── MailwizzApiController.php │ │ │ └── UserController.php │ │ └── PublicPageController.php │ ├── Middleware/ │ │ └── CheckRole.php │ └── Requests/ │ ├── StorePageRequest.php │ ├── UpdatePageRequest.php │ └── SubscribeRequest.php ├── Jobs/ │ └── SyncSubscriberToMailwizz.php ├── Models/ │ ├── User.php │ ├── PreregistrationPage.php │ ├── Subscriber.php │ └── MailwizzConfig.php ├── Policies/ │ └── PreregistrationPagePolicy.php ├── Services/ │ └── MailwizzService.php └── Providers/ resources/views/ ├── admin/ │ ├── layouts/ │ │ └── app.blade.php (backend layout with sidebar nav) │ ├── dashboard.blade.php │ ├── pages/ │ │ ├── index.blade.php │ │ ├── create.blade.php │ │ ├── edit.blade.php │ │ └── _form.blade.php (shared form partial) │ ├── subscribers/ │ │ └── index.blade.php │ ├── mailwizz/ │ │ └── edit.blade.php (configuration wizard) │ └── users/ │ ├── index.blade.php │ ├── create.blade.php │ └── edit.blade.php └── public/ └── page.blade.php (the public pre-registration page) database/ ├── migrations/ │ ├── xxxx_add_role_to_users_table.php │ ├── xxxx_create_preregistration_pages_table.php │ ├── xxxx_create_subscribers_table.php │ └── xxxx_create_mailwizz_configs_table.php └── seeders/ └── SuperadminSeeder.php (creates initial superadmin account) ``` --- ## 12. Seeder: Initial Superadmin ```php // database/seeders/SuperadminSeeder.php public function run(): void { User::updateOrCreate( ['email' => 'admin@preregister.app'], [ 'name' => 'Super Admin', 'password' => Hash::make('changeme123!'), 'role' => 'superadmin', 'email_verified_at' => now(), ] ); } ``` > **Post-deploy:** Change the password immediately. --- ## 13. Key Implementation Details ### 13.1 Image Upload Handling ```php // In PageController store/update methods: if ($request->hasFile('background_image')) { // Delete old image if replacing if ($page->background_image) { Storage::disk('public')->delete($page->background_image); } $path = $request->file('background_image')->store('pages/backgrounds', 'public'); $page->background_image = $path; } ``` Run `php artisan storage:link` to create the public symlink. ### 13.2 Alpine.js Countdown Timer ```html