laika-framework

Deployment

Entry Points

All web traffic must be routed through public/index.php — it loads lf-boot/app.php and calls Url::dispatch(). The framework itself then decides which static files may be served (see lf-config/assets.php).

Point the web server’s document root at public/, not at the project root. public/ holds only the front controller and its .htaccess, so lf-*, vendor/, composer.json and lf-storage/keys/app.key can’t be reached by URL at all. Static files (assets/, template/assets/, uploads/) stay in the project root and are served through PHP, so their URLs don’t change.

The project root also ships a deny-all .htaccess, which blocks everything if a vhost is pointed at the project root by mistake.

Apache

public/.htaccess ships with the rewrite rule in place (and php laika app:sync regenerates it if it’s missing):

<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{HTTP:Authorization} .
    RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
    RewriteRule ^ index.php [QSA,L]
</IfModule>

mod_rewrite must be enabled and AllowOverride must allow FileInfo for the directory.

<VirtualHost *:80>
    ServerName example.com
    DocumentRoot /var/www/example.com/public

    <Directory /var/www/example.com/public>
        AllowOverride FileInfo
        Require all granted
    </Directory>
</VirtualHost>

nginx

Do not hand-write this. php laika nginx:server prints a complete, self-contained server block — deny rules, front-controller rewrite and PHP handler all in it, root set to public/. It includes no other file from the project, so it is the only thing you need:

php laika nginx:server --domain=example.com --php=8.3 --output=/etc/nginx/sites-available/example.com
ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

Without --output it prints to stdout so you can read it first. --root names the project path on the target server, without /public — pass it when you generate the config somewhere other than where it will run, or it defaults to this machine’s path.

If you already maintain an nginx config

php laika nginx:make writes those same rules to nginx.conf in the project root as a fragment you include from a server block you own. Use this only when nginx:server’s block doesn’t fit what you already have.

File What it is Where it goes
The server block Complete server { ... }, from nginx:server Your nginx config, e.g. /etc/nginx/sites-available/example.com
nginx.conf The same rules as a fragment, from nginx:make Stays in the project root, included by a server block you wrote

The two are alternatives, not a pair — pick one. If you use nginx.conf, your own block must set root to <project>/public, and it must define location = /index.php above which the include sits:

server {
    server_name example.com;
    root /var/www/app/public;
    index index.php;

    include /var/www/app/nginx.conf;

    location = /index.php {
        fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root/index.php;
    }
}

Never point nginx:server --output at that nginx.conf. It is meant to be included from inside a server block, so a server block written there nests one in the other and nginx fails with "server" directive is not allowed here — and the deny rules are lost. The command refuses that target; if it already happened, php laika nginx:make --force regenerates the file.

The front-controller rewrite

Both outputs carry the same rewrite:

location / {
    if ($http_authorization != "") {
        set $auth_header $http_authorization;
    }

    rewrite ^ /index.php last;
}

Every request is rewritten to public/index.php, including ones that map to a real file, so the framework decides what may be served rather than nginx handing files out before PHP runs. The cost is that static assets go through PHP instead of nginx’s static path — see Static file caching below for what that does and does not cost.

location = /index.php is what terminates that rewrite — without it nginx loops and returns 500. It also means no PHP file other than the front controller can ever be executed directly. The generated server block already has it, below the rewrite.

Static file caching

Static files are served by Laika\Engine\Route\Asset, not by the web server, so the cache policy comes from lf-config/assets.php rather than from an expires directive. Out of the box every response carries:

Header  
ETag, Last-Modified a conditional request is answered 304 with no body
Cache-Control per-extension max_age; immutable for a URL carrying ?v=
Accept-Ranges: bytes Range requests get a 206, so audio and video seek

Do not add expires or Cache-Control directives in nginx or Apache for these paths — they would fight the per-extension policy. Tune lf-config/assets.php instead.

URLs from asset() and enqueue_* carry ?v={mtime}, so they are served immutable and the browser reuses its copy without revalidating until the file itself changes.

A static file is answered during boot, before the function and hook files load — that is what keeps it from paying for the framework it does not use. The trade-off is that a hook cannot influence an asset response: MimeType::register() and the CORS:: setters have no effect there. CORS itself still runs, so preflights and cross-origin fonts are unaffected.

The one thing PHP still does is move the bytes, which holds an FPM worker for the whole transfer. If that matters — large media, many concurrent downloads — hand the transfer to the server with the sendfile key, which keeps the authorisation decision in PHP and the I/O out of it.

nginx ('sendfile' => 'x-accel-redirect'). The app sends the file’s path relative to the project root, not to public/, so nginx needs an internal location covering the roots you serve from, with its own root set to the project root:

location ~ ^/(assets|uploads|template)/ {
    internal;
    root /var/www/example.com;
}

internal is what makes this safe: the location is reachable only from an X-Accel-Redirect, never from the outside, so requests still enter through public/index.php and nginx moves the bytes only once PHP has named the file. Add any other servable root to that regex, or the app will point nginx at a location it does not handle and the response will be empty.

Apache ('sendfile' => 'x-sendfile') needs mod_xsendfile:

XSendFile On
XSendFilePath /var/www/example.com

Without the matching server config either setting produces empty responses, so change it on a staging host first.

PHP-FPM

Every laikait/* package runs under PHP-FPM. php laika nginx:server already targets an FPM socket (unix:/var/run/php/php<version>-fpm.sock, override with --fastcgi=127.0.0.1:9000). A few things differ from Apache mod_php:

Pre-Deploy Checklist

Running With DEBUG Off

DEBUG = false changes four things:

  DEBUG = true DEBUG = false
Error page Whoops, with the stack trace A generic “Internal Server Error” page
Error log lf-logs/{Y}-{Mon}-{d}-error.log Nothing is logged by the framework
Resource discovery Scans directories on every request Uses lf-storage/cache/resources.php when it exists
Twig templates Recompiled when the source changes Compiled once; edits need php laika cache:clear --templates

Two consequences:

Database Tables

php laika app:migrate runs your own schemas. These framework tables are not created by it. Most install themselves on first use, which needs CREATE TABLE rights once; the queue tables never do:

Table Created
options The first time Option / option() is used
activities The first time Activity::insert() writes
sessions Init::model('default', install: true)
auth_tokens A token guard with 'install' => true
laika_queue_jobs, laika_failed_jobs Never automatically — only by the calls below

If the runtime database user can’t run DDL, create them once with a privileged connection — a custom command is a good place:

(new \Laika\Engine\Schema\OptionSchema('default'))->up();
(new \Laika\Engine\Schema\ActivitySchema('default'))->up();
(new \Laika\Engine\Session\Schema\SessionSchema('default'))->up();
(new \Laika\Engine\Auth\Schema\AuthSchema('default'))->up();
(new \Laika\Engine\Queue\Schema\QueueModelSchema())->up();      // database queue driver; follows queue.connection
(new \Laika\Engine\Queue\Schema\FailedJobModelSchema())->up();  // database failed-job store

Timezone

The framework sets PHP’s default timezone to UTC at boot. Store dates in UTC, and convert for display — or set your own zone in a hook file:

// lf-hooks/timezone.php
\Laika\Engine\Services\Date::setAppTimezone('Asia/Dhaka');

For MySQL, add 'timezone' => '+00:00' to the connection so the database session agrees with PHP.

Memory Limits

lf-inc/const.php defines MEMORY_LIMIT and CLI_MEMORY_LIMIT. The Core module applies them to memory_limit at boot, through Laika\Engine\System\MemoryManager::apply():

Process Limit
Web requests MEMORY_LIMIT
php laika ..., php worker CLI_MEMORY_LIMIT

They only ever lower the limit below php.ini (or set one when it’s unlimited). To give a process more memory, raise memory_limit in php.ini or the FPM pool as well. The queue worker also exits gracefully at about 90% of its memory_limit, for the supervisor to restart.

laika-core 5.1.1 and earlier applied CLI_MEMORY_LIMIT in the queue worker only; everything else used php.ini’s limit.

Queue Worker

The worker executable (see Queue) is a long-running process, not a cron job — keep it alive with a process supervisor. One process handles one queue.

supervisor:

; /etc/supervisor/conf.d/laika-worker.conf
[program:laika-worker-default]
command=php /var/www/myapp/worker default
directory=/var/www/myapp
user=www-data
autostart=true
autorestart=true
numprocs=1
stopsignal=TERM
stopwaitsecs=70

systemd:

# /etc/systemd/system/laika-worker@.service
[Unit]
Description=Laika queue worker (%i)
After=network.target

[Service]
User=www-data
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/php /var/www/myapp/worker %i
Restart=always
KillSignal=SIGTERM
TimeoutStopSec=70

[Install]
WantedBy=multi-user.target
systemctl enable --now laika-worker@default laika-worker@emails

What Not to Deploy

CI

The skeleton’s GitHub Actions workflows:

Adding php laika resource:list to CI catches classes that don’t load or don’t satisfy their contract — it exits non-zero when it finds one.