<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Adyru</title>
    <description>The latest articles on DEV Community by Adyru (@adyru).</description>
    <link>https://dev.to/adyru</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4122996%2F15d35e93-3516-4ae8-9d87-5fee6889c3f3.png</url>
      <title>DEV Community: Adyru</title>
      <link>https://dev.to/adyru</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adyru"/>
    <language>en</language>
    <item>
      <title>Shipping a 200-controller Laravel SaaS on shared hosting (no SSH, no Artisan)</title>
      <dc:creator>Adyru</dc:creator>
      <pubDate>Sun, 13 Sep 2026 09:40:24 +0000</pubDate>
      <link>https://dev.to/adyru/shipping-a-200-controller-laravel-saas-on-shared-hosting-no-ssh-no-artisan-268l</link>
      <guid>https://dev.to/adyru/shipping-a-200-controller-laravel-saas-on-shared-hosting-no-ssh-no-artisan-268l</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploys are file uploads, so make them boring
&lt;/h2&gt;

&lt;p&gt;Without SSH, a deploy is a zip upload and an extract in the file manager. Two things make that survivable.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Second, cache clearing needs a route, and that route needs a secret:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/clear'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;abort_unless&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.clear_token'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'token'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="mi"&gt;404&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nc"&gt;Artisan&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'optimize:clear'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'cleared'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two hard-won notes.&lt;/p&gt;

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

&lt;p&gt;And do not end that endpoint with a full &lt;code&gt;optimize&lt;/code&gt;. On any app with closure routes, &lt;code&gt;route:cache&lt;/code&gt; cannot serialise closures, so it throws — &lt;em&gt;after&lt;/em&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations become idempotent SQL
&lt;/h2&gt;

&lt;p&gt;No Artisan means no &lt;code&gt;migrate&lt;/code&gt;. Every schema change ships as a &lt;code&gt;.sql&lt;/code&gt; file that is safe to run twice, because eventually somebody runs it twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;gos_projects&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;   &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InnoDB&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;CHARSET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;utf8mb4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;gos_plans&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'launch'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;DUPLICATE&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Blade trap that cost us a day
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@media (min-width: 640px) and (prefers-color-scheme: dark) { ... }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A &lt;code&gt;#&lt;/code&gt; hex colour inside the parentheses, or a &lt;code&gt;//&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;@@media&lt;/code&gt; where you want literal output.&lt;/p&gt;

&lt;h2&gt;
  
  
  .env is not a shell script, until it is
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;APP_NAME&lt;/span&gt;=&lt;span class="n"&gt;Adyru&lt;/span&gt; &lt;span class="n"&gt;Growth&lt;/span&gt; &lt;span class="n"&gt;OS&lt;/span&gt;      &lt;span class="c"&gt;# 500s the whole site
&lt;/span&gt;&lt;span class="n"&gt;APP_NAME&lt;/span&gt;=&lt;span class="s2"&gt;"Adyru Growth OS"&lt;/span&gt;    &lt;span class="c"&gt;# fine
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;.env&lt;/code&gt; changes with the same care as a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ship security headers in stages
&lt;/h2&gt;

&lt;p&gt;We added a &lt;code&gt;SecurityHeaders&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Would I choose this? No
&lt;/h2&gt;

&lt;p&gt;If you can have a VPS, have one. But the constraint produced three habits worth keeping:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Idempotent SQL for every schema change.&lt;/li&gt;
&lt;li&gt;Self-contained modules, with no foreign keys leaving the set.&lt;/li&gt;
&lt;li&gt;No deploy may depend on a command the environment cannot run.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We are &lt;a href="https://adyru.com" rel="noopener noreferrer"&gt;Adyru&lt;/a&gt;, a technology group in Dubai. If you want to check your own stack the lazy way, our &lt;a href="https://adyru.com/audit" rel="noopener noreferrer"&gt;instant site audit&lt;/a&gt; runs 28 SEO, speed, security and mobile checks on any URL with no signup, and there are &lt;a href="https://adyru.com/tools" rel="noopener noreferrer"&gt;525 more free tools&lt;/a&gt; beside it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>php</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
