The standard advice says a modern Laravel app needs a VPS: SSH, a deploy tool, artisan on the box. My portfolio runs on Namecheap Stellar shared hosting, where I have cPanel, FTP, and nothing resembling a shell. I shipped a Laravel 12 + React 19 rebuild there anyway, with a rollback path under five minutes. Here is the actual mechanics, including the two traps that cost me an evening each.

The model: the server never builds anything

Shared hosting fails when you ask it to run composer or npm. So it never does. Everything is built where I do have a shell, and the server receives a finished artifact.

The build: composer install --no-dev --optimize-autoloader --classmap-authoritative, then npm ci && npm run build. Bake route:cache and view:cache into the artifact. The artifact is the entire app root including vendor/ and public/build, excluding .env, node_modules, .git, and the runtime caches. FTP the whole thing. It is not elegant. It is deterministic, and deterministic wins.

stderr: lesson. Do not bake config:cache into the artifact unless production env values are injected at build time. A config cache built against your local .env ships your local config to production and ignores the server's .env entirely. route:cache and view:cache are safe to bake; config is not.

Document root without root

Laravel wants the web root pointed at public/, not the app root. cPanel can change the primary domain's document root, and that is the clean path. The fallback when the host resists: copy the contents of public/ into public_html and edit the two require paths in index.php to point at the app folder living beside it. Ugly, documented, works.

Staging happens on a subdomain with a noindex header, pointed at its own copy of the artifact. Every gate runs there first: curl each route and confirm the full HTML, title, meta, and JSON-LD are present before any JavaScript, then a real contact submit to prove CSRF and mail work end to end.

Rollback is a rename

Cutover is a document root swap, not a DNS move, which is what makes it reversible. Zip and download the live CI4 site first. Rename its folder aside; never delete it. Point the docroot at the new app's public/. If anything regresses, pointing the docroot back is the entire rollback. The triggers are pre-committed, decided while calm: contact mail failing, missing security headers, or Search Console showing deindexing inside 72 hours. You do not want to be inventing rollback criteria while production is wrong.

Security when you cannot touch the server config

No shell also means no server-level firewall rules, so the app carries its own. A global middleware 403s the background noise every public site gets: /wp-admin probes, SQLi strings, path traversal.

stderr: lesson. That middleware must be registered globally, not in the web group. Unmatched paths like /wp-admin 404 during routing before group middleware ever runs, so a web-group probe blocker silently never fires. Global registration also puts your security headers on 404 responses, which is where scanners look first.

HSTS and forced HTTPS upgrades are production-only flags, so local HTTP development does not fight the browser's HSTS cache. That one is cheap to set up and miserable to debug after the fact.

The performance work the host cannot do for you

Shared hosting will not give you an edge CDN or fancy image transforms, so the asset pipeline happens at build time too. The home page's LCP element, a full-viewport poster, went from 1.19MB PNG to 59KB WebP with one ImageMagick command at quality 82, plus a separate portrait crop for phones. Two media-scoped preload hints mean each viewport downloads exactly one poster. Total page weight after: 10KB CSS gzip, 62KB JS gzip, 59KB image.

One measurement warning: php artisan serve is single-threaded and serves assets serially, so Lighthouse under network throttling reports a wildly inflated LCP against the dev server. Mine showed 2830ms throttled locally and 532ms with normal delivery. Take the authoritative number on the production host, where HTTP/2 parallelizes delivery.

Two runtime gotchas that only show up on this kind of deploy: a leftover public/hot file from npm run dev makes Vite point every asset at a dead dev server, and Laravel's throttle limiter counts in minutes and persists in the cache store, so a "broken" contact form during testing may just be a rate limit you tripped an hour ago.

This deploy model is the same discipline I bring to client work: respect the constraint, make the pipeline deterministic, and make the rollback boring. The rebuild this shipped is its own case study.

// available for contract work

This is the same work I do for clients: a hard performance budget, real constraints, and an experience that outlives the first visit.

also on dev.to · rss