Shipping a 200-controller Laravel SaaS on shared hosting

Our platform is a large Laravel application: 200+ controllers, 130+ models, CRM, payments, wallets, campaign tooling, client reporting. It runs on ordinary cPanel shared hosting. No SSH. No Artisan. No queue workers. No Docker.

That is not a boast and it was not the plan. It is where the client was, and moving them was not on the table. Here is what we learned making a big framework behave in a small box.

Deploys are file uploads, so make them boring

Without SSH, a deploy is a zip upload and an extract in the file manager. Two things make that survivable.

First, never let a deploy depend on a command you cannot run. Everything that would normally be an Artisan call needs a file-based or HTTP-based equivalent.

Second, cache clearing needs a route, and that route needs a secret:

Route::get('/clear', function (Request $r) {
abort_unless(
hash_equals(config('app.clear_token'), (string) $r->query('token')),
404
);

Artisan::call('optimize:clear');

return response('cleared', 200);
});

Two hard-won notes.

A token that ever appeared in a throwaway script under public/ is burned. Rotate it and delete the script — assume anything you left in the web root has been read.

And do not end that endpoint with a full optimize. On any app with closure routes, route:cache cannot serialise closures, so it throws — after the caches have already been cleared. You get a 500 and a working site, which makes for a confusing five minutes. Clearing is safe; caching is the part that needs care.

Migrations become idempotent SQL

No Artisan means no migrate. Every schema change ships as a .sql file that is safe to run twice, because eventually somebody runs it twice:

CREATE TABLE IF NOT EXISTS gos_projects (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(32) NOT NULL UNIQUE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO gos_plans (id, code) VALUES (1, 'launch')
ON DUPLICATE KEY UPDATE code = VALUES(code);

ADD COLUMN IF NOT EXISTS is tempting, but support varies by version and fork — MariaDB has it, MySQL 8 does not. The portable version is a check against information_schema before the ALTER. Test it on the actual host, not on your laptop.

Keep the migration files in the repo anyway, so moving to a real host later is a catch-up command rather than an archaeology project.

A side effect we did not expect: forcing every module to own a self-contained set of tables, with no foreign keys reaching outside it, made those modules genuinely portable. We have since lifted two of them into other projects unchanged.

The Blade trap that cost us a day

This is the part worth the read. Blade compiles directives by matching balanced parentheses, and it does not know it is looking at CSS. So this breaks:

@media (min-width: 640px) and (prefers-color-scheme: dark) { ... }

A # hex colour inside the parentheses, or a // sequence, can derail the directive parser. The symptom is spectacular and misleading: raw CSS and script text printed above the doctype, which looks exactly like a header leak in a middleware or a service provider. We spent several rounds debugging the wrong layer.

The fix: keep hex colours and comment-looking sequences out of directive parentheses. Put the value in a custom property declared elsewhere, or escape the directive as @@media where you want literal output.

.env is not a shell script, until it is

APP_NAME=Adyru Growth OS      # 500s the whole site
APP_NAME="Adyru Growth OS" # fine

One unquoted string with a space took a production site down. On a host where you cannot tail a log over SSH, a five-second edit becomes a twenty-minute diagnosis. Treat .env changes with the same care as a deploy.

Ship security headers in stages

We added a SecurityHeaders middleware with a CSP that has three modes driven by env: compat, report and strict. Compat keeps legacy inline scripts alive, report sends violations without blocking, strict enforces. Extra origins come from env keys rather than code edits, so adding a payment widget does not need a deploy.

On a host with no observability, a report mode you can flip from a text file is worth more than a perfect policy you are afraid to enable.

Would I choose this? No

If you can have a VPS, have one. But the constraint produced three habits worth keeping:

  1. Idempotent SQL for every schema change.
  2. Self-contained modules, with no foreign keys leaving the set.
  3. No deploy may depend on a command the environment cannot run.

All three make the app easier to move, which is the opposite of what you would expect from the most locked-in hosting there is.


We are Adyru, a technology group in Dubai. If you want to check your own stack the lazy way, our instant site audit runs 28 SEO, speed, security and mobile checks on any URL with no signup, and there are 525 more free tools beside it.


Sumber Rujukan:

Comments

Similar Posts