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 --seedregularly. - 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_keycolumn MUST use Laravel'sencryptedcast 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
- Install Laravel Breeze (Blade stack) for login/register scaffolding.
- Disable public registration — only superadmin can create users. Remove the registration routes or gate them behind superadmin middleware.
- Create a
CheckRolemiddleware:
// 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);
}
- Register in
bootstrap/app.phpas aliasrole. - 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
});
- Add a Policy for
PreregistrationPage:superadmincan do everything.usercan 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 forsuperadmin) - 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:
-
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
-
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, storegeneral.list_uid) - "Load Fields" button → fetches fields for the selected list
- On valid API key: make a server-side call to
-
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.identifieristextAND (tagcontainsEMAILor is standard email field)Dropdown of matching fields (display label, storetag)First Name Show fields where type.identifieristextDropdown Last Name Show fields where type.identifieristextDropdown Phone Show fields where type.identifierisphonenumberDropdown (only shown if phone_enabled) Tag / Source Show fields where type.identifierischeckboxlistDropdown of checkboxlist fields - Fetch fields from
-
Select Tag Value
- After selecting the checkboxlist field, load its
optionsobject - 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
- After selecting the checkboxlist field, load its
-
Save Configuration
- Store all mappings in the
mailwizz_configstable - Show success message with summary of the configuration
- Store all mappings in the
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:
- Load existing value
- Split by comma
- Add the new value if not present
- 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
debuglevel
10. Security Checklist
- CSRF protection on all forms (Blade
@csrfdirective) - API key encryption using Laravel's
encryptedcast — 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
$fillableon every model - HTTPS only — set
APP_URLwithhttps://, enablesecurecookies 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:
- PHP strict types — every PHP file starts with
declare(strict_types=1); - Type hints everywhere — method parameters, return types, properties
- No magic strings — use constants or enums for repeated values (roles, statuses)
- Form Request classes — all validation in dedicated Form Request classes, not inline
- Single responsibility — controllers are thin, business logic in services/jobs
- Blade components — use
<x-component>syntax for reusable UI elements - No
dd()in committed code — use proper logging - Database transactions — wrap multi-step operations in
DB::transaction() - Error handling — never swallow exceptions. Log and re-throw or return user-friendly error.
- 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:linkto expose uploads - Run
php artisan queue:work --queue=mailwizz(or configure Supervisor) for async Mailwizz sync - Run
php artisan migrate --seedon first deploy - Set up a cron for
php artisan schedule:run(for any future scheduled tasks) - Ensure
APP_KEYis 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:
- Open the project folder in Cursor
- Add this entire document as context (paste in chat or reference as file)
- Use
@Codebaseto let Cursor see existing files - Work through the Development Sequence (Section 15) step by step
- Use Cursor's "Apply" feature to write directly to files
- After each phase, run
php artisan serveand test in browser - 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:
- Start in the project root directory
- Read this entire document first before writing any code
- Execute the Development Sequence (Section 15) in order
- Use
bashto run Artisan commands and verify state - Create files with proper directory structure
- After creating migrations, always run
php artisan migrate - After modifying routes, run
php artisan route:listto verify - 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 tinkerto test model relationships - Use
php artisan make:*commands to generate boilerplate where possible