Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 044 / 100

CORS in Laravel: PHP & Laravel best practices explained

Configure cross-origin requests correctly.

Expona Admin7 min read

Understanding CORS and why it matters in Laravel

Cross-Origin Resource Sharing (CORS) is one of those security mechanisms that can feel like friction when you're building APIs, but it's absolutely critical when you're running a frontend on one domain and an API on another. We've all hit that browser console error where a preflight request gets rejected, stalling the entire feature and forcing us to dig through configuration. The good news: once you understand how CORS works in Laravel and configure it properly, you'll stop fighting it and start using it as the first line of defense for your API.

CORS in Laravel: PHP & Laravel best practices explained means understanding that this isn't just about throwing `*` at the problem. It's about being intentional with your origins, headers, and credentials so your frontend can talk to your API securely without opening yourself to unauthorized cross-origin requests.

The config/cors.php file: your single source of truth

Laravel makes CORS configuration centralized through the `config/cors.php` file, which ships with the framework when you install the CORS middleware. This file controls which origins are allowed to make requests to your API, what HTTP methods they can use, which headers they can send, and whether credentials are supported.

Let's start with a real-world scenario: you're running a React SPA on `https://app.example.com` and your Laravel API on `https://api.example.com`. Your `config/cors.php` might look like this:

php
<?php

return [
    'paths' => ['api/*', 'sanctum/csrf-token'],
    'allowed_methods' => ['*'],
    'allowed_origins' => ['https://app.example.com'],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['*'],
    'exposed_headers' => [],
    'max_age' => 0,
    'supports_credentials' => false,
];

The `paths` array tells Laravel which routes should go through the CORS middleware. The `allowed_origins` array is where you list the exact domains that are permitted to make requests. Notice we're being specific: only `https://app.example.com`, not `*.example.com` or `*`. This specificity is the first level of security.

The `allowed_methods` array controls HTTP verbs. In development, setting this to `['*']` is convenient, but in production, we recommend being explicit: `['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS']`. The OPTIONS method is crucial—browsers send a preflight OPTIONS request before actual requests when certain conditions are met (like custom headers or credentials), and Laravel needs to respond to that.

Listing exact origins for credentials: no shortcuts

When your frontend needs to send authentication cookies or tokens, credentials become part of the conversation. This is where many teams make mistakes. If `supports_credentials` is true, you cannot use wildcard origins. The browser will reject the request.

Imagine you're building a travel SPA that makes authenticated requests to book flights. Your frontend at `https://bookings.travel.io` needs to send cookies with every request to `https://api.travel.io`. Here's what your configuration needs to look like:

php
<?php

return [
    'paths' => ['api/*', 'sanctum/csrf-token'],
    'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
    'allowed_origins' => [
        'https://bookings.travel.io',
        'https://admin.travel.io',  // if you have multiple frontends
    ],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['Content-Type', 'Authorization', 'Accept'],
    'exposed_headers' => ['X-Total-Count', 'X-Page-Count'],
    'max_age' => 86400,
    'supports_credentials' => true,
];

Notice the change: `supports_credentials` is now true, and we've listed every single origin that's allowed. We've also narrowed `allowed_headers` to the specific ones we need. The `exposed_headers` array tells the browser which response headers the JavaScript can access—this is useful if you're paginating results and returning metadata in custom headers.

In your frontend code, you'd then configure fetch or axios to include credentials:

php
// JavaScript fetch example
fetch('https://api.travel.io/bookings', {
    method: 'POST',
    credentials: 'include',  // This sends cookies
    headers: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + token
    },
    body: JSON.stringify(bookingData)
});

Allowing required headers for your API contract

Your API often needs more than just the default headers. Maybe you're versioning your API and expecting an `X-API-Version` header. Maybe you're passing tenant identifiers via custom headers in a SaaS environment. The `allowed_headers` configuration controls what custom headers your API will accept.

Let's look at a CRM platform scenario. Your SaaS app runs at `https://crm.company.io`, and authenticated users need to specify which tenant they're accessing. You might configure it this way:

php
<?php

return [
    'paths' => ['api/*', 'sanctum/csrf-token'],
    'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
    'allowed_origins' => ['https://crm.company.io'],
    'allowed_origins_patterns' => [],
    'allowed_headers' => [
        'Content-Type',
        'Authorization',
        'Accept',
        'X-Tenant-ID',
        'X-API-Version',
        'X-Request-ID'
    ],
    'exposed_headers' => ['X-Response-Time', 'X-Request-ID'],
    'max_age' => 86400,
    'supports_credentials' => true,
];

When the browser sees that your JavaScript is sending an `X-Tenant-ID` header, it will make a preflight OPTIONS request first. Laravel's CORS middleware checks `allowed_headers`, and if your custom header is listed, it responds with a 200. Then the actual request proceeds. If the header isn't listed, the preflight fails and the browser never sends the real request.

Sanctum SPA authentication and supports_credentials

Laravel Sanctum is the go-to authentication system for SPAs, and it relies heavily on proper CORS configuration. When you're using Sanctum's SPA guard, the flow typically involves:

  1. Frontend makes a request to `/sanctum/csrf-token` to get a CSRF token
  2. Frontend stores this token and includes it in subsequent requests
  3. Frontend sends cookies (Laravel session cookie) with all requests
  4. Backend verifies the session and CSRF token

For this to work, credentials must be supported. Here's a production-ready Sanctum SPA configuration:

php
<?php

return [
    'paths' => ['api/*', 'sanctum/csrf-token'],
    'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
    'allowed_origins' => [
        'https://app.example.com',
    ],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['Content-Type', 'Authorization', 'Accept'],
    'exposed_headers' => [],
    'max_age' => 86400,
    'supports_credentials' => true,
];

Your SPA would authenticate like this:

php
// Get CSRF token
await fetch('https://api.example.com/sanctum/csrf-token', {
    method: 'GET',
    credentials: 'include',
});

// Login
const response = await fetch('https://api.example.com/login', {
    method: 'POST',
    credentials: 'include',
    headers: {
        'Content-Type': 'application/json',
    },
    body: JSON.stringify({ email, password })
});

// Make authenticated API call
const data = await fetch('https://api.example.com/api/user', {
    method: 'GET',
    credentials: 'include',  // Sends the session cookie
});

The middleware route registration in `app/Http/Kernel.php` or your route file should ensure the CORS middleware wraps your API routes:

php
Route::middleware(['cors'])->group(function () {
    Route::post('/login', [AuthController::class, 'login']);
    Route::get('/user', [UserController::class, 'show'])->middleware('auth:sanctum');
});

Common mistakes and how to avoid them

Mistake 1: Using wildcard origins with credentials. If you set `allowed_origins` to `['*']` and `supports_credentials` to true, the browser will reject it. The CORS spec forbids this for security reasons. Either list specific origins or disable credentials.

Mistake 2: Forgetting to include the OPTIONS method. Browsers will make preflight requests using OPTIONS. If your `allowed_methods` doesn't include it or doesn't use wildcard, the preflight fails. Always ensure OPTIONS is allowed.

Mistake 3: Not exposing necessary response headers. If your frontend code tries to read a custom response header (like pagination metadata), but that header isn't in `exposed_headers`, JavaScript will see it as inaccessible. Remember: you control what headers clients can read via the `exposed_headers` configuration.

Mistake 4: Misconfiguring paths. The `paths` array is a list of route patterns that should have CORS applied. If you're protecting `/api/*` but not `/sanctum/csrf-token`, your Sanctum setup will fail. Make sure both are listed.

Mistake 5: Hardcoding environment-specific origins. Use environment variables for your allowed origins so you don't accidentally ship production origins in development config:

php
<?php

return [
    'paths' => ['api/*', 'sanctum/csrf-token'],
    'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
    'allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', 'http://localhost:3000')),
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['*'],
    'exposed_headers' => [],
    'max_age' => 86400,
    'supports_credentials' => true,
];

Then in your `.env` file:

php
CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com

Key takeaways

  • Always be explicit with allowed origins; never use wildcards when credentials are involved.
  • Configure `config/cors.php` to list every origin that needs access to your API.
  • Include custom headers in the `allowed_headers` array so preflight requests pass.
  • Set `supports_credentials` to true only when your frontend sends cookies or tokens, and ensure all origins are explicitly listed.
  • For Sanctum SPA authentication, include both `api/*` and `sanctum/csrf-token` in the paths array.
  • Use environment variables to manage different CORS policies across development, staging, and production.
  • Test CORS configuration by checking the browser Network tab for preflight OPTIONS requests and their response headers.
  • Remember that CORS is a browser-enforced security feature; it doesn't protect server-to-server or mobile app communication.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles