Skip to content

Solution, not issue: Caddy & Laravel in Dev #123

@anothersailor

Description

@anothersailor

Working with Laravel and Caddy.

Here is a Caddy file that works with page-cache, with the cache directory /public/page-cache/:

{
	http_port 80
	https_port 443

	# --- Logs ---
	log {
		output file /project_path/storage/logs/caddy.log
		format json
		level ERROR
	}
}

localvb2.test:443 {
	tls internal

	# --- Logs ---
	log {
		output file /project_path/storage/logs/caddy.log
		format json
		level INFO
	}

	# --- Default root: Laravel project root ---
	root * /project_path
	# --- Security headers ---
	header {
		X-Frame-Options SAMEORIGIN
		X-Content-Type-Options nosniff
		Referrer-Policy "strict-origin-when-cross-origin"
	}

	# --- Vite build assets ---
	@vite_build {
		path /build/*
	}
	handle @vite_build {
		root * /project_path/public
		file_server
	}

	# --- Laravel static assets ---
	# Exclude page-cache directory - it's handled by the page cache handler below
	@laravel_static {
		path_regexp \.(html|ico|css|js|gif|jpe?g|png|svg|woff2?|ttf|pdf|txt|json)$
		not path_regexp ^/page-cache/
	}

	# --- Page Cache: Check for cached files before Laravel ---
	# When request comes in for /path/url, try to serve /public/page-cache/path/url.html
	# If cache file doesn't exist, request continues to Laravel
	@page_cache {
		path_regexp ^/(fr|en)/(boutique|shop)/.*
		not path_regexp \.(html|php)$
		file {
			try_files /public/page-cache{path}.html
		}
	}

	handle @page_cache {
		header X-Cache-Status "from-cache"
		# Switch root ONLY for cached pages
		root * /project_path/public/page-cache
		rewrite * {path}.html
		file_server
	}

	# --- Laravel (default fallback) ---
	# Everything else that hasn't been handled above goes to Laravel
	handle {
		root * //project_path/public
		php_fastcgi 127.0.0.1:9000
		file_server
	}

	# --- Compression ---
	encode gzip zstd
}

The other thing to note is that the cache pages should be generated only when Vite is in Build mode.
The links to the static assets will be wrong if the page is cached in dev mode (when they are served by the Vite server).

Solution is to extend Silber's middleware BaseCacheResponse (see in the documentation), and add:

use Illuminate\Support\Facades\Vite;

if (Vite::isRunningHot()) {
    // Do NOT cache this page
    Log::debug('Page is not cached: Vite is running hot'.$request->getUri());
    return false;
}

A third question that needs to be handled (in Dev like in Prod) is the question of the CSRF tokens.
When the user loads a cached page, the CSRF that comes with it belongs to an old, stale session. So when the backend receives a POST request, it should refuse it.

The solution is to add a js script to the pages, that refreshes the CSRF token when it is refused (note: I am sure it can be optimised). The advantage is that it also avoids the cases when a user is filling the form, and all of a sudden his token expires.

resources/js/utils/csrf.js

/**
 * Global CSRF Token Management
 * Provides utilities for handling CSRF tokens across all pages
 */

// Get current locale from URL or default to 'en'
const getCurrentLocale = () => {
    const path = window.location.pathname;
    const match = path.match(/^\/(en|fr)\//);
    return match ? match[1] : 'en';
};

// Get CSRF token from meta tag
export const getCsrfToken = () => {
    return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
};

// Update CSRF token in meta tag
export const updateCsrfToken = (newToken) => {
    const metaTag = document.querySelector('meta[name="csrf-token"]');
    if (metaTag) {
        metaTag.setAttribute('content', newToken);
    }
};

// Refresh CSRF token from server
export const refreshCsrfToken = async () => {
    try {
        const locale = getCurrentLocale();
        const url = `/${locale}/csrf-token`;
        
        const response = await fetch(url, {
            method: 'GET',
            headers: {
                'Accept': 'application/json',
            },
            credentials: 'same-origin',
        });
        
        if (response.ok) {
            const data = await response.json();
            if (data.token) {
                updateCsrfToken(data.token);
                return data.token;
            }
        }
    } catch (error) {
        console.error('Error refreshing CSRF token:', error);
    }
    return null;
};

/**
 * Global authenticated fetch wrapper
 * Automatically handles CSRF token refresh on 419 errors
 * 
 * @param {string} url - The URL to fetch
 * @param {object} options - Fetch options
 * @returns {Promise<Response>}
 */
export const authenticatedFetch = async (url, options = {}) => {
    const makeRequest = async (token, retryCount = 0) => {
        const headers = {
            'Content-Type': 'application/json',
            'X-CSRF-TOKEN': token,
            'Accept': 'application/json',
            ...options.headers,
        };
        
        const response = await fetch(url, {
            ...options,
            headers,
            credentials: 'same-origin',
        });
        
        // Check if response is HTML (CSRF token expired or page expired)
        const contentType = response.headers.get('content-type');
        const isHtml = contentType && contentType.includes('text/html');
        
        // Handle 419 (CSRF token mismatch) or HTML response (Page Expired)
        if (response.status === 419 || (isHtml && response.status === 200)) {
            // Only retry once to avoid infinite loops
            if (retryCount < 1) {
                const newToken = await refreshCsrfToken();
                if (newToken) {
                    return makeRequest(newToken, retryCount + 1);
                }
            }
            // If refresh failed or max retries reached, reload page
            window.location.reload();
            return null;
        }
        
        // Check if response is OK
        if (!response.ok) {
            // If 419 error, try to refresh token
            if (response.status === 419 && retryCount < 1) {
                const newToken = await refreshCsrfToken();
                if (newToken) {
                    return makeRequest(newToken, retryCount + 1);
                }
                window.location.reload();
                return null;
            }
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        // Check if response is JSON (if we expected JSON)
        if (!contentType || !contentType.includes('application/json')) {
            // If we got HTML but expected JSON, likely CSRF issue
            if (isHtml && retryCount < 1) {
                const newToken = await refreshCsrfToken();
                if (newToken) {
                    return makeRequest(newToken, retryCount + 1);
                }
                window.location.reload();
                return null;
            }
        }
        
        return response;
    };
    
    try {
        return await makeRequest(getCsrfToken());
    } catch (error) {
        // If it's a JSON parse error, likely got HTML response - try refresh
        if (error instanceof SyntaxError) {
            const newToken = await refreshCsrfToken();
            if (newToken) {
                try {
                    return await makeRequest(newToken, 1);
                } catch (retryError) {
                    console.error('Error after token refresh:', retryError);
                    window.location.reload();
                    return null;
                }
            }
            window.location.reload();
            return null;
        }
        throw error;
    }
};

// Make authenticatedFetch available globally
window.authenticatedFetch = authenticatedFetch;

Apologies if all this looks obvious to many of the readers. I am still learning and was not suspecting all these details.
Maybe it could be added to the readme ?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions