<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Adyru Engineering]]></title><description><![CDATA[Adyru Engineering]]></description><link>https://adyru.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6aa66d67daecc8656517bd2a/ad515789-0a09-4f3e-b17b-cd6c025cd3ed.png</url><title>Adyru Engineering</title><link>https://adyru.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 20 Sep 2026 03:56:17 GMT</lastBuildDate><atom:link href="https://adyru.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Shipping a 200-controller Laravel SaaS on shared hosting (no SSH, no Artisan)]]></title><description><![CDATA[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. N]]></description><link>https://adyru.hashnode.dev/shipping-a-200-controller-laravel-saas-on-shared-hosting-no-ssh-no-artisan</link><guid isPermaLink="true">https://adyru.hashnode.dev/shipping-a-200-controller-laravel-saas-on-shared-hosting-no-ssh-no-artisan</guid><category><![CDATA[Laravel]]></category><category><![CDATA[PHP]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Adyru]]></dc:creator><pubDate>Sun, 13 Sep 2026 09:52:56 GMT</pubDate><content:encoded><![CDATA[<p>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.</p>
<p>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.</p>
<h2>Deploys are file uploads, so make them boring</h2>
<p>Without SSH, a deploy is a zip upload and an extract in the file manager. Two things make that survivable.</p>
<p>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.</p>
<p>Second, cache clearing needs a route, and that route needs a secret:</p>
<pre><code class="language-php">Route::get('/clear', function (Request $r) {
    abort_unless(
        hash_equals(config('app.clear_token'), (string) $r-&gt;query('token')),
        404
    );

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

    return response('cleared', 200);
});
</code></pre>
<p>Two hard-won notes.</p>
<p>A token that ever appeared in a throwaway script under <code>public/</code> is burned. Rotate it and delete the script — assume anything you left in the web root has been read.</p>
<p>And do not end that endpoint with a full <code>optimize</code>. On any app with closure routes, <code>route:cache</code> cannot serialise closures, so it throws — <em>after</em> 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.</p>
<h2>Migrations become idempotent SQL</h2>
<p>No Artisan means no <code>migrate</code>. Every schema change ships as a <code>.sql</code> file that is safe to run twice, because eventually somebody runs it twice:</p>
<pre><code class="language-sql">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);
</code></pre>
<p><code>ADD COLUMN IF NOT EXISTS</code> is tempting, but support varies by version and fork — MariaDB has it, MySQL 8 does not. The portable version is a check against <code>information_schema</code> before the <code>ALTER</code>. Test it on the actual host, not on your laptop.</p>
<p>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.</p>
<p>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.</p>
<h2>The Blade trap that cost us a day</h2>
<p>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:</p>
<pre><code class="language-blade">@media (min-width: 640px) and (prefers-color-scheme: dark) { ... }
</code></pre>
<p>A <code>#</code> hex colour inside the parentheses, or a <code>//</code> 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.</p>
<p>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 <code>@@media</code> where you want literal output.</p>
<h2>.env is not a shell script, until it is</h2>
<pre><code>APP_NAME=Adyru Growth OS      # 500s the whole site
APP_NAME="Adyru Growth OS"    # fine
</code></pre>
<p>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 <code>.env</code> changes with the same care as a deploy.</p>
<h2>Ship security headers in stages</h2>
<p>We added a <code>SecurityHeaders</code> 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.</p>
<p>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.</p>
<h2>Would I choose this? No</h2>
<p>If you can have a VPS, have one. But the constraint produced three habits worth keeping:</p>
<ol>
<li>Idempotent SQL for every schema change.</li>
<li>Self-contained modules, with no foreign keys leaving the set.</li>
<li>No deploy may depend on a command the environment cannot run.</li>
</ol>
<p>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.</p>
<hr />
<p><em>We are <a href="https://adyru.com">Adyru</a>, a technology group in Dubai. If you want to check your own stack the lazy way, our <a href="https://adyru.com/audit">instant site audit</a> runs 28 SEO, speed, security and mobile checks on any URL with no signup, and there are <a href="https://adyru.com/tools">525 more free tools</a> beside it.</em></p>
]]></content:encoded></item></channel></rss>