A living document of every real error, quirk, and gotcha encountered in this project. Purpose: Save time. When you hit an error, search this file first. Convention: Errors are grouped by category. Each entry has: what it is, when it happens, why, and the exact fix.
Contributing: When you fix a new issue that isn't here, add it. Future you (and future developers) will be grateful.
- Authentication & CSRF
- WebSockets / Reverb
- Stripe / Payments / Webhooks
- Multi-Tenancy
- Database & Migrations
- Queues & Jobs
- Frontend / Vite / Inertia
- Testing
- Scout / Typesense Search
- Deployment / CI / cPanel
- Filament Admin Panel
- Sanctum / API Auth
- Rector / Pint / Static Analysis
Context: Happens after fresh installs, after pulling changes, or when switching environments. Login, registration, and checkout forms all return 419.
Cause: Laravel's CSRF middleware can't read the token. In an Inertia/SPA setup this usually means bootstrap.js isn't sending the X-CSRF-TOKEN header, or the cookie isn't being forwarded.
Solution:
- Make sure
bootstrap.jsreads the token from the meta tag:window.axios.defaults.headers.common['X-CSRF-TOKEN'] = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
- Run
php artisan config:clear && php artisan cache:clear. - Hard-refresh the browser (
Ctrl+Shift+R) to clear stale cookies.
Context: Stripe webhook deliveries returning 419 in logs.
Cause: Laravel's VerifyCsrfToken middleware fires before the webhook controller. Stripe sends raw POST data without a CSRF token.
Solution: Exempt the webhook route in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'webhooks/stripe',
]);
})Commit reference: c57f17c
Context: After login, users are bounced back to /login instead of /en (the default locale-prefixed home).
Cause: The default redirectTo in the authentication middleware points to /home, which doesn't exist in this localized routing setup.
Solution: Set explicit redirects in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->redirectGuestsTo(fn () => route('login'));
$middleware->redirectAuthenticatedUsersTo('/en');
})Commit reference: c57f17c
Context: Happens after restarting the machine, pulling new Git changes, or starting a new dev session.
Cause: The Reverb server process isn't running.
Solution:
- Start Reverb:
php artisan reverb:start - Clear config cache:
php artisan config:clear - Restart the frontend dev server:
npm run dev(orcomposer run devto start everything at once)
Context: Happens on environments where VITE_REVERB_APP_KEY isn't set in .env.
Cause: bootstrap.js unconditionally initialises Echo/Pusher even when the key is absent.
Solution: Guard the Echo initialisation:
if (import.meta.env.VITE_REVERB_APP_KEY) {
window.Echo = new Echo({ ... });
}Commit reference: 224b709
Context: Works locally, breaks on staging/production.
Cause: Missing or wrong REVERB_* environment variables. Also check that the reverse-proxy (nginx/Apache) is forwarding WebSocket upgrade headers.
Solution:
- Verify all four env vars are set:
REVERB_APP_ID,REVERB_APP_KEY,REVERB_APP_SECRET,REVERB_HOST. - For cPanel/shared hosting that doesn't support persistent processes, consider using Pusher as a fallback instead of self-hosted Reverb.
- Run
php artisan reverb:start --daemon(or configure a supervisor process).
Context: Testing checkout locally with Stripe CLI.
Cause: The STRIPE_WEBHOOK_SECRET in .env is stale or doesn't match the current CLI listener session.
Solution:
- Start the CLI listener:
stripe listen --forward-to localhost/webhooks/stripe - Copy the new
whsec_...secret printed by the CLI. - Update
STRIPE_WEBHOOK_SECRETin.env. - Run
php artisan config:clear.
Context: Checkout completes on the frontend but the order stays in pending status indefinitely.
Cause: The payment_intent.succeeded webhook event never arrived (CLI not running, wrong webhook secret, or webhook route CSRF-blocked).
Solution:
- Check Stripe Dashboard → Developers → Webhooks → recent events to confirm delivery.
- Ensure the CSRF exemption for
/webhooks/stripeis in place (see Authentication section above). - Run
stripe listenlocally and watch for delivery errors. - Re-trigger a failed event from the Stripe Dashboard.
Context: The POST /api/v1/checkout response doesn't include payment_intent.client_secret.
Cause: The PaymentIntent model wasn't storing client_secret in the database. Fixed in a past migration.
Solution:
- Run
php artisan migrateto ensure theclient_secretcolumn exists onpayment_intents. - Check the
PaymentIntentmodel hasclient_secretin its$fillablearray.
Commit reference: 440d6e6 / 53b123d
Context: User clicks "Continue to Payment" twice quickly.
Cause: The frontend wasn't generating or persisting an idempotency key per checkout session.
Solution: The checkout composable (useCheckout.ts) generates a key and stores it in sessionStorage. The key is sent as the Idempotency-Key header on the POST /api/v1/checkout request. The backend IdempotencyKey domain deduplicates it. Don't remove this mechanism.
Context: Feature tests that don't set up tenant context. Also happens locally if you hit an API endpoint without a valid tenant subdomain or API token tied to a tenant.
Cause: The BelongsToTenant trait applies a global TenantScope that filters all queries by Context::get('tenant_id'). If the context isn't set, nothing is returned.
Solution (tests):
// Use the WithTenant trait in your test class
uses(WithTenant::class);
beforeEach(function () {
$this->setUpTenant(); // sets Context::add('tenant_id', ...)
});Solution (local dev): Hit the API via a subdomain (acme-store.localhost) or use actingAs() with a user who has a tenant_id.
Context: A queued job creates a Product or Order and the tenant_id is null in the database.
Cause: Laravel queues serialize and deserialize the job. The Context facade state (which holds tenant_id) is not automatically carried across queue worker boundaries.
Solution: The AppServiceProvider propagates tenant context through queues using:
Context::dehydrating(fn ($context) => $context->add('tenant_id', Context::get('tenant_id')));
Context::hydrated(fn ($context) => Context::add('tenant_id', $context->get('tenant_id')));If you ever clear or re-register the AppServiceProvider, ensure these hooks are still present.
Context: Creating a super-admin User (tenant_id = null) from a test or seeder and the tenant_id gets set to the current tenant.
Cause: The original BelongsToTenant trait used if ($this->tenant_id === null) which also caught explicitly set null values.
Solution: The trait now uses array_key_exists to distinguish "not set" from "explicitly null":
if (!array_key_exists('tenant_id', $this->attributes)) {
$this->tenant_id = Context::get('tenant_id');
}Commit reference: 0136ce7
Context: Running php artisan migrate fails with SQLSTATE[23000]: Integrity constraint violation on a unique index.
Cause: The old unique index (e.g., products_sku_unique) doesn't include tenant_id, so two tenants with the same SKU violate it.
Solution: In the migration, drop the old index and recreate it with tenant_id as the first column:
$table->dropUnique(['sku']);
$table->unique(['tenant_id', 'sku']);Context: You modify a column (e.g., change nullable to non-nullable) and existing attributes disappear.
Cause: Laravel 12 requires all previously-defined column attributes to be repeated in ->change() calls. Omitting them drops those attributes silently.
Solution: Always include the full column definition:
// Wrong — drops nullable
$table->string('email')->change();
// Correct — preserves nullable
$table->string('email')->nullable()->change();Context: Running tests with RefreshDatabase or running migrate:fresh locally.
Cause: A migration creates a table that another migration also tries to create (duplicate migration naming or a down() method that doesn't drop the table).
Solution:
- Check for duplicate
Schema::create('table_name')calls across migrations. - Ensure every
up()has a matchingSchema::dropIfExists()indown(). - Run
php artisan migrate:freshon a clean database.
Context: Deploying to shared hosting that uses MariaDB with the MyISAM engine.
Cause: The config/database.php engine key was hardcoded to null instead of reading from an env var.
Solution: Set DB_ENGINE=InnoDB (or MyISAM) in .env. The config reads:
'engine' => env('DB_ENGINE', null),Commit reference: 224b709
Context: A job is dispatched but nothing happens. No exception is thrown.
Cause: Queue worker isn't running.
Solution:
- Start the worker:
php artisan queue:work - For development:
php artisan queue:listen --tries=1(better stack traces) - Check
QUEUE_CONNECTIONin.env— should bedatabaseorredis, notsyncin production.
Context: A job that calls an external API (Stripe, exchange rates) keeps failing and being retried.
Cause: The job has $tries > 1 and the failure isn't a permanent one, but the API is consistently down.
Solution:
- Check
failed_jobstable:php artisan queue:failed - Retry:
php artisan queue:retry all - Flush stale failures:
php artisan queue:flush - For permanent failures, implement
failed()method on the job to handle cleanup.
Context: Happens after pulling new changes or after the first deploy when assets haven't been built.
Cause: The compiled asset manifest (public/build/manifest.json) is missing or stale.
Solution:
- Development: Run
npm run devorcomposer run dev - Production: Run
npm run buildbefore deploying - CI/CD: The build step must run before the deploy step (see
.github/workflows/deploy.yml)
Commit reference: 440d6e6
Context: You add a new Tailwind class in a .vue file and it doesn't appear in the browser.
Cause: Tailwind v4 scans sources at build time. If the dev server isn't running with HMR, changes aren't picked up immediately.
Solution:
- If running
npm run dev: the class should appear within seconds (HMR). Hard-refresh if it doesn't. - If building for production: run
npm run build. - If the class still doesn't appear, check
app.css— the@sourcedirective must include'../js/**/*.vue'(or similar glob matching your component path).
Context: After adding new palette entries to @theme in app.css, the classes aren't generated.
Cause: Tailwind v4 generates utilities from @theme at build time. A syntax error or invalid OKLCH value silently skips the palette.
Solution:
- Validate the OKLCH value: lightness must be
0–1, chroma0–0.4, hue0–360. - Run
npm run buildand check the terminal for CSS parse errors. - Confirm you're using
@import "tailwindcss"(v4 syntax), not@tailwind base/components/utilities(v3 deprecated syntax).
Context: A page component tries to access nested props before they arrive (e.g., user.profile.avatar).
Cause: Inertia v2 deferred props arrive after the initial render. The component renders once with null/undefined before the data loads.
Solution: Use optional chaining throughout: user?.profile?.avatar. For deferred props, add skeleton loaders using the .skeleton CSS utility class.
Context: A component uses $listeners (Vue 2 API) to detect if a parent has bound an event handler.
Cause: $listeners was removed in Vue 3. It was merged into $attrs.
Solution: Use $attrs instead, or pass an explicit boolean prop:
<!-- Instead of $listeners?.rowClick -->
:class="onRowClick ? 'cursor-pointer' : ''"Context: Feature tests that hit API endpoints return 404 for every request.
Cause: The test doesn't call setUpTenant(), so the global TenantScope filters out all models. The factory creates the model in a different (auto-created) tenant context.
Solution: Add the WithTenant trait and call setUpTenant() in beforeEach:
uses(RefreshDatabase::class, WithTenant::class);
beforeEach(function () {
$this->setUpTenant();
// Now Context::get('tenant_id') is set and factories respect it
});Context: A test creates a model and immediately queries for it with a raw SQL filter.
Cause A: SQLite integer division returns 0 for 2/3 (truncates, doesn't round). A whereRaw like refunded_count / total_count >= 0.5 always returns 0 in SQLite.
Solution: Cast to float in SQLite:
CAST(refunded_count AS REAL) / CAST(total_count AS REAL) >= 0.5Cause B: The model uses the BelongsToTenant global scope and the test doesn't have tenant context. (See 404 entry above.)
Context: Running seeders or factories in Laravel 12.
Cause: Laravel 12 no longer injects $this->faker into factories automatically. The property is null.
Solution: Use the fake() helper function instead:
// Wrong
'name' => $this->faker->name(),
// Correct
'name' => fake()->name(),Commit reference: 224b709
Context: Running php artisan db:seed multiple times (local dev, CI re-runs).
Cause: The seeder uses Promotion::create() unconditionally and the second run tries to insert a duplicate.
Solution: Use firstOrCreate() or check existence before creating:
Promotion::firstOrCreate(
['code' => 'SUMMER20'],
[...attributes...]
);Commit reference: 224b709
Context: Writing assertions that check multiple values on different subjects.
Cause: In Pest, continued assertions on a new subject use ->and(newValue), not ->expect(newValue).
Solution:
// Wrong
expect($order->status)->toBe('pending')
->expect($order->total)->toBe(5000); // ← invalid
// Correct
expect($order->status)->toBe('pending')
->and($order->total)->toBe(5000); // ← use ->and()Context: After a fresh install or after seeding data, Typesense search returns empty.
Cause: Models haven't been indexed yet. Scout doesn't automatically index existing records — only new/updated ones.
Solution:
- Run the manual re-index:
php artisan scout:import "App\Domain\Product\Models\Product" - Or use the admin panel UI button (Scout Search Admin Trigger feature —
/control-plane/scout-reindex).
Context: Running a search before the collection has been created.
Cause: The Typesense collection schema is created when the first record is indexed. If the database is empty, the collection doesn't exist.
Solution:
- Seed the database:
php artisan db:seed - Index the models:
php artisan scout:import "App\Domain\Product\Models\Product" - Or create the collection manually with
php artisan scout:sync-index-settings.
Context: After deployment.
Cause: The TYPESENSE_API_KEY, TYPESENSE_HOST, and TYPESENSE_PORT environment variables aren't set on the production server.
Solution: Add to the production .env:
SCOUT_DRIVER=typesense
TYPESENSE_API_KEY=your-key
TYPESENSE_HOST=localhost
TYPESENSE_PORT=8108
TYPESENSE_PROTOCOL=httpContext: GitHub Actions deploy workflow starts running at the same time as the CI test workflow.
Cause: The original setup used wait-on-check-action which fired the gate check before CI jobs were even registered by GitHub.
Solution: Use workflow_run trigger in the deploy workflow so it only runs after CI completes:
on:
workflow_run:
workflows: ["CI"]
types: [completed]
branches: [main]Commit reference: 2f252d5
Context: The deploy script runs npm run build on the cPanel server and it fails.
Cause: cPanel shared hosting doesn't have Node.js/npm in the PATH for SSH sessions.
Solution: Build assets on the CI runner (GitHub Actions) and SCP the compiled public/build/ directory to the server — don't build on the server.
- name: Build assets
run: npm ci && npm run build
- name: Deploy built assets
run: scp -r public/build user@host:public_html/public/buildCommit reference: 440d6e6
Context: The deploy SSH script runs composer install and it fails.
Cause: Composer is installed at a non-standard path on cPanel (e.g., ~/bin/composer.phar or /usr/local/bin/composer).
Solution: Detect the Composer path dynamically:
COMPOSER=$(which composer || which composer.phar || echo ~/bin/composer.phar)
$COMPOSER install --no-dev --optimize-autoloaderCommit reference: be5778f
Context: Running php artisan db:seed on production after deploying with composer install --no-dev.
Cause: fakerphp/faker was in require-dev in composer.json. Seeders (called via DatabaseSeeder) depend on it at runtime in production.
Solution: Move fakerphp/faker to require (not require-dev):
"require": {
"fakerphp/faker": "^1.23"
}Commit reference: b6dcf60
Context: After running php artisan optimize or after a fresh install.
Cause A: Filament's cached views are stale.
Solution: php artisan filament:optimize-clear followed by php artisan optimize.
Cause B: Custom Blade components referenced in a panel provider don't exist yet.
Solution: Publish and compile Filament views: php artisan vendor:publish --tag=filament-views.
Context: A super-admin user can't perform a delete/edit action on a resource.
Cause: The resource has a Policy registered, and the Gate check for the action fails because the policy's before() hook isn't returning true for super-admins.
Solution: Add a before() method to the policy:
public function before(User $user): ?bool
{
if ($user->isSuperAdmin()) {
return true;
}
return null; // fall through to individual methods
}Error: canAccess() method on a custom Filament page doesn't respect tenant admin vs super-admin roles
Context: Both tenant admins and super-admins should see the Fraud Dashboard, but one group is blocked.
Cause: The canAccess() check used a combined || expression that short-circuits incorrectly when either method throws.
Solution: Use explicit null-safe calls and separate checks:
public static function canAccess(): bool
{
$user = auth()->user();
if ($user?->isAdmin() === true) {
return true;
}
return $user?->isSuperAdmin() === true;
}Commit reference: 819b24c
Context: The Inertia frontend hits /api/v1/... endpoints and gets 401, even after logging in.
Cause: The frontend's domain isn't in Sanctum's stateful domains list.
Solution: Add the local dev domain to SANCTUM_STATEFUL_DOMAINS in .env:
SANCTUM_STATEFUL_DOMAINS=localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,127.0.0.1:8001Or update config/sanctum.php directly. Remember to run php artisan config:clear after.
Commit reference: 819b24c
Context: The Sanctum guard is set to web, but the request has an Authorization: Bearer token.
Cause: Sanctum tries cookie auth first, then token auth. If the CSRF cookie isn't present (e.g., the browser blocked it or SameSite policy is strict), cookie auth fails. Bearer token auth requires the api guard.
Solution:
- Ensure
GET /sanctum/csrf-cookieis called before any stateful API request. - Make sure the API route uses the
auth:sanctummiddleware (not justauth). - For local HTTPS issues with
SameSite=Laxcookies, setSESSION_SECURE_COOKIE=falsein.env.
Context: Any env('FOO') call directly in a non-config PHP file triggers a Rector rule failure.
Cause: The project enforces the Laravel best practice: env() should only be in config/*.php files. Application code must use config('key').
Solution: Move the value to a config file and call config():
// Wrong — in a controller/action/model
$key = env('STRIPE_KEY');
// Correct — in config/services.php
'stripe' => ['key' => env('STRIPE_KEY')],
// Then in your code
$key = config('services.stripe.key');Context: env('DB_ENGINE', null) causes a Rector rule violation about redundant default values.
Cause: Rector flags env('X', null) as a redundant default since env() already returns null if the variable is absent.
Solution: Remove the default:
// Wrong
'engine' => env('DB_ENGINE', null),
// Correct
'engine' => env('DB_ENGINE'),Commit reference: eeb0716
Context: After running vendor/bin/pint --dirty, a null check gets rewritten into an instanceof check that doesn't behave the same way.
Cause: Pint applies certain opinionated PHP-CS-Fixer rules. The rule may change semantics if the variable can be non-null falsy values.
Solution: This is intentional — the instanceof form is more explicit and type-safe. Review the change carefully. If the logic differs, add a // @phpcs:ignore comment or configure the rule in pint.json.
Last updated: April 2026