Files
preregister/documentation/Pregister-Development-Prompt.md

39 KiB

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

# 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.

// 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

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

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

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:
// 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);
}
  1. Register in bootstrap/app.php as alias role.
  2. Apply to routes:
Route::middleware(['auth', 'role:superadmin'])->group(function () {
    // User management routes
});

Route::middleware(['auth', 'role:superadmin,user'])->group(function () {
    // Pre-registration page management routes
});
  1. Add a Policy for PreregistrationPage:
    • superadmin can do everything.
    • user can only view/edit/delete own pages.

6. Route Structure

// ─── 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):

// 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 <body> 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

// 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:

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

// 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

// 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

<!-- In public/page.blade.php -->
<div x-data="countdown('{{ $page->start_date->toIso8601String() }}')"
     x-init="start()">
    <div class="flex gap-4 justify-center text-white text-center">
        <div>
            <span class="text-4xl font-bold" x-text="days">00</span>
            <span class="text-sm block">days</span>
        </div>
        <div>
            <span class="text-4xl font-bold" x-text="hours">00</span>
            <span class="text-sm block">hours</span>
        </div>
        <div>
            <span class="text-4xl font-bold" x-text="minutes">00</span>
            <span class="text-sm block">minutes</span>
        </div>
        <div>
            <span class="text-4xl font-bold" x-text="seconds">00</span>
            <span class="text-sm block">seconds</span>
        </div>
    </div>
</div>

<script>
function countdown(targetDate) {
    return {
        days: '00', hours: '00', minutes: '00', seconds: '00',
        interval: null,
        start() {
            this.update();
            this.interval = setInterval(() => this.update(), 1000);
        },
        update() {
            const diff = new Date(targetDate) - new Date();
            if (diff <= 0) {
                clearInterval(this.interval);
                window.location.reload(); // Reload to show form
                return;
            }
            this.days   = String(Math.floor(diff / 86400000)).padStart(2, '0');
            this.hours  = String(Math.floor((diff % 86400000) / 3600000)).padStart(2, '0');
            this.minutes = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0');
            this.seconds = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0');
        }
    };
}
</script>

13.3 Form Submission with Alpine.js

<div x-data="registrationForm()" x-show="!submitted">
    <form @submit.prevent="submit">
        <!-- fields with x-model bindings -->
        <button type="submit" :disabled="loading">
            <span x-show="!loading">Register</span>
            <span x-show="loading">Submitting...</span>
        </button>
        <p x-show="error" x-text="error" class="text-red-300 mt-2"></p>
    </form>
</div>

<div x-show="submitted" class="text-center text-white">
    <p x-text="successMessage"></p>
</div>

13.4 CSV Export

// SubscriberController@export
public function export(PreregistrationPage $page)
{
    $this->authorize('view', $page);

    $subscribers = $page->subscribers()->orderBy('created_at')->get();

    return response()->streamDownload(function () use ($subscribers, $page) {
        $handle = fopen('php://output', 'w');
        fputcsv($handle, ['First Name', 'Last Name', 'Email', 'Phone', 'Registered At']);
        foreach ($subscribers as $sub) {
            fputcsv($handle, [
                $sub->first_name,
                $sub->last_name,
                $sub->email,
                $sub->phone,
                $sub->created_at->toDateTimeString(),
            ]);
        }
        fclose($handle);
    }, "subscribers-{$page->slug}.csv");
}

14. Coding Standards & Conventions

For AI assistants (Claude Code & Cursor)

Follow these rules strictly:

  1. PHP strict types — every PHP file starts with declare(strict_types=1);
  2. Type hints everywhere — method parameters, return types, properties
  3. No magic strings — use constants or enums for repeated values (roles, statuses)
  4. Form Request classes — all validation in dedicated Form Request classes, not inline
  5. Single responsibility — controllers are thin, business logic in services/jobs
  6. Blade components — use <x-component> syntax for reusable UI elements
  7. No dd() in committed code — use proper logging
  8. Database transactions — wrap multi-step operations in DB::transaction()
  9. Error handling — never swallow exceptions. Log and re-throw or return user-friendly error.
  10. Comments — only for why, never for what. The code should be self-documenting.

Git Workflow

When using Claude Code or Cursor in agentic mode:

Commit after each logical unit:
1. Migrations + Models                → "feat: add database schema and models"
2. Auth + Middleware + Policies       → "feat: add auth, roles and authorization"
3. Backend CRUD for pages             → "feat: add pre-registration page management"
4. Public frontend                    → "feat: add public registration pages"
5. Subscriber management              → "feat: add subscriber list and export"
6. Mailwizz configuration UI          → "feat: add Mailwizz configuration wizard"
7. Mailwizz sync job                  → "feat: add Mailwizz subscriber sync"
8. User management (superadmin)       → "feat: add user management for superadmin"
9. Polish, validation, error handling → "fix: add validation and error handling"

15. Development Sequence (Step-by-Step for AI)

Instructions for Claude Code / Cursor: Execute these steps in order. Complete each step fully before moving to the next. Test each step.

Phase 1: Foundation

Step 1: Create the Laravel project and install dependencies
  - laravel/breeze (blade)
  - tailwindcss, alpinejs (via breeze/vite)
  - Configure .env for MySQL

Step 2: Create all migrations (in the order specified in Section 3)
  - Run php artisan migrate

Step 3: Create all Eloquent models with relationships, casts, fillable (Section 4)

Step 4: Set up authentication
  - Modify Breeze to disable public registration
  - Add CheckRole middleware
  - Create PreregistrationPagePolicy
  - Create SuperadminSeeder and run it

Step 5: Set up route structure (Section 6)

Phase 2: Backend

Step 6: Build the admin layout (sidebar, navigation, responsive)

Step 7: Build the Dashboard page

Step 8: Build Pre-Registration Page CRUD
  - Index with status indicators
  - Create/Edit form with image uploads
  - Delete with confirmation
  - Storage link for public images

Step 9: Build Subscriber index
  - Paginated table
  - Search/filter
  - CSV export

Step 10: Build User Management (superadmin only)
  - CRUD for users with role assignment

Phase 3: Public Frontend

Step 11: Build the public page view
  - Three states: countdown, form, expired
  - Full-screen background + overlay + centered card
  - Alpine.js countdown timer
  - Alpine.js form submission (AJAX)
  - Mobile responsive

Step 12: Build the subscription endpoint
  - Validation
  - Duplicate check
  - Store subscriber
  - Return JSON for Alpine.js

Phase 4: Mailwizz Integration

Step 13: Create MailwizzService class

Step 14: Build Mailwizz configuration wizard
  - API key input + validation
  - List selection (AJAX)
  - Field mapping (AJAX)
  - Tag/source selection
  - Save configuration

Step 15: Create SyncSubscriberToMailwizz job
  - Search existing subscriber
  - Create or update with tag merging
  - Error handling and retries
  - Mark as synced

Step 16: Wire up the job dispatch in PublicPageController@subscribe

Phase 5: Polish

Step 17: Add rate limiting to public endpoints
Step 18: Add flash messages / toast notifications in backend
Step 19: Add form validation error display (Blade error bags)
Step 20: Test the full flow end-to-end
Step 21: Write a README.md with setup instructions

16. Environment Variables

APP_NAME=PreRegister
APP_URL=https://preregister.yourdomain.com

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=preregister
DB_USERNAME=preregister
DB_PASSWORD=

QUEUE_CONNECTION=database

# No Mailwizz env vars — API keys are per-page and stored encrypted in DB

17. Deployment Notes

  • Run php artisan storage:link to expose uploads
  • Run php artisan queue:work --queue=mailwizz (or configure Supervisor) for async Mailwizz sync
  • Run php artisan migrate --seed on first deploy
  • Set up a cron for php artisan schedule:run (for any future scheduled tasks)
  • Ensure APP_KEY is set and backed up
  • Configure HTTPS via reverse proxy (Nginx/Caddy)

Appendix A: Mailwizz API Quick Reference

Action Method URL Notes
Get all lists GET /api/lists Paginated
Get list fields GET /api/lists/{list_uid}/fields All custom fields
Search subscriber GET /api/lists/{list_uid}/subscribers/search-by-email?EMAIL={email} Returns subscriber_uid
Get subscriber GET /api/lists/{list_uid}/subscribers/{subscriber_uid} Full subscriber data
Create subscriber POST /api/lists/{list_uid}/subscribers Form-data body
Update subscriber PUT /api/lists/{list_uid}/subscribers/{subscriber_uid} Form-data body

All requests require header: X-Api-Key: {your_api_key}

Checkboxlist field handling:

  • Stored internally as comma-separated string: "tag1,tag2,tag3"
  • When sending via API, send as array: TAGS[]=tag1&TAGS[]=tag2
  • When reading, split the string by comma to get individual values
  • When updating, merge existing values with new value, deduplicate, send as array

Appendix B: Cursor-Specific Instructions

When using this prompt with Cursor:

  1. Open the project folder in Cursor
  2. Add this entire document as context (paste in chat or reference as file)
  3. Use @Codebase to let Cursor see existing files
  4. Work through the Development Sequence (Section 15) step by step
  5. Use Cursor's "Apply" feature to write directly to files
  6. After each phase, run php artisan serve and test in browser
  7. Use the terminal panel to run Artisan commands

Cursor Rules (add to .cursorrules)

You are working on PreRegister, a Laravel 11 application.
- Always use PHP 8.2+ strict types
- Follow PSR-12 coding standards
- Use Laravel conventions (route model binding, form requests, policies)
- Use Blade + Tailwind + Alpine.js (no React, no Vue, no Livewire)
- All controller methods must have return type hints
- Use the Service pattern for external API calls (MailwizzService)
- Never expose API keys in logs, responses, or frontend code
- Test after each feature: php artisan serve + browser check

Appendix C: Claude Code-Specific Instructions

When using this prompt with Claude Code:

  1. Start in the project root directory
  2. Read this entire document first before writing any code
  3. Execute the Development Sequence (Section 15) in order
  4. Use bash to run Artisan commands and verify state
  5. Create files with proper directory structure
  6. After creating migrations, always run php artisan migrate
  7. After modifying routes, run php artisan route:list to verify
  8. Commit after each logical step with conventional commit messages

Claude Code Tips

  • When creating Blade views, always create the layout first
  • When creating controllers, create the Form Request classes at the same time
  • When adding a new route, immediately create the controller method (even as a stub)
  • For the Mailwizz wizard: build the backend API endpoints first, then the Alpine.js frontend
  • Use php artisan tinker to test model relationships
  • Use php artisan make:* commands to generate boilerplate where possible