<?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: Bytephase Technologies Pvt Ltd</title>
    <description>The latest articles on DEV Community by Bytephase Technologies Pvt Ltd (@bytephase-team).</description>
    <link>https://dev.to/bytephase-team</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%2F1132747%2F843545c2-ab04-4f66-80c4-0027ce779b25.jpeg</url>
      <title>DEV Community: Bytephase Technologies Pvt Ltd</title>
      <link>https://dev.to/bytephase-team</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bytephase-team"/>
    <language>en</language>
    <item>
      <title>How a cached 301 put our homepage in a redirect loop (CloudFront + WordPress)</title>
      <dc:creator>Bytephase Technologies Pvt Ltd</dc:creator>
      <pubDate>Mon, 05 Oct 2026 09:51:02 +0000</pubDate>
      <link>https://dev.to/bytephase/how-a-cached-301-put-our-homepage-in-a-redirect-loop-cloudfront-wordpress-4hme</link>
      <guid>https://dev.to/bytephase/how-a-cached-301-put-our-homepage-in-a-redirect-loop-cloudfront-wordpress-4hme</guid>
      <description>&lt;p&gt;One morning our marketing site's homepage started failing with &lt;code&gt;ERR_TOO_MANY_REDIRECTS&lt;/code&gt;. Every other page was fine. The cause turned out to be a perfectly reasonable cache setting meeting a perfectly reasonable SEO plugin. Here's the chain, because it's an easy one to walk into.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two reasonable settings
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The SEO plugin strips tracking parameters.&lt;/strong&gt; Our WordPress site (&lt;a href="https://bytephase.com/" rel="noopener noreferrer"&gt;bytephase.com&lt;/a&gt;) uses Yoast, which can 301-redirect &lt;code&gt;/?fbclid=abc&lt;/code&gt; or &lt;code&gt;/?utm_source=x&lt;/code&gt; to the clean URL &lt;code&gt;/&lt;/code&gt;. That's good for duplicate-URL hygiene.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The CDN ignores tracking parameters in the cache key.&lt;/strong&gt; In CloudFront we excluded &lt;code&gt;utm_*&lt;/code&gt;, &lt;code&gt;gclid&lt;/code&gt;, &lt;code&gt;fbclid&lt;/code&gt; and &lt;code&gt;msclkid&lt;/code&gt; from the cache policy. Otherwise every ad click creates a fresh cache entry and a cache miss. Also good.&lt;/p&gt;

&lt;h2&gt;
  
  
  How they combine
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A visitor arrives at &lt;code&gt;/?fbclid=abc&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;CloudFront drops &lt;code&gt;fbclid&lt;/code&gt; from the &lt;strong&gt;cache key&lt;/strong&gt;, so the request maps to the cache entry for &lt;code&gt;/&lt;/code&gt;. But the origin request policy still &lt;strong&gt;forwards&lt;/strong&gt; &lt;code&gt;fbclid&lt;/code&gt; to WordPress.&lt;/li&gt;
&lt;li&gt;WordPress sees the parameter and answers &lt;code&gt;301 → /&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;CloudFront caches that 301… under the key for &lt;code&gt;/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The next visitor requests &lt;code&gt;/&lt;/code&gt;, gets the cached &lt;code&gt;301 → /&lt;/code&gt;, follows it, and gets the same 301. Loop.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The homepage was the only victim because it's where ad and social traffic lands.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgkm6mr5d0kmutpp9waxk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgkm6mr5d0kmutpp9waxk.png" alt="Six-step diagram of a CloudFront redirect loop: tracking parameter dropped from the cache key but forwarded to WordPress, whose 301 gets cached under the homepage key" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;The cache key and the origin request must agree on which parameters exist. If CloudFront ignores a parameter for caching, it must also &lt;strong&gt;not forward it&lt;/strong&gt; to the origin.&lt;/p&gt;

&lt;p&gt;We switched the origin request policy from "all viewer query strings" to &lt;strong&gt;all except&lt;/strong&gt; the same list the cache policy excludes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;utm&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;utm&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;medium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;utm&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;campaign&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;utm&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;term&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="k"&gt;utm&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;utm&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;gclid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fbclid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;msclkid&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now WordPress never sees &lt;code&gt;fbclid&lt;/code&gt;, so it never issues the redirect, and nothing poisonous gets cached. Analytics still works, because GA reads the parameters client-side from the browser URL, not from the server.&lt;/p&gt;

&lt;p&gt;Then we invalidated &lt;code&gt;/*&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two gotchas while verifying
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your browser caches 301s too.&lt;/strong&gt; After the edge was fixed, Chrome kept looping from its own redirect cache. Test with a fresh profile, or &lt;code&gt;fetch(url, {cache: 'reload'})&lt;/code&gt;, before declaring it still broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The loop can return quietly.&lt;/strong&gt; Add a parameter to the cache-key exclusions later without adding it to the origin exclusions, and the same chain re-forms. We now keep the two lists side by side in the same change.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Any time a CDN normalises a request (drops parameters, headers or cookies from the cache key), ask: &lt;em&gt;can the origin answer differently based on the thing I just dropped?&lt;/em&gt; If yes, either keep it in the key or stop forwarding it. Redirects are the worst case, because they're cacheable and they point at themselves.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We build &lt;a href="https://bytephase.com/" rel="noopener noreferrer"&gt;repair shop management software&lt;/a&gt; at BytePhase. The marketing site is WordPress behind CloudFront, and we write up the infrastructure lessons here as we hit them.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>cloudfront</category>
      <category>wordpress</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Database-per-tenant in Laravel: 6 lessons from running a multi-tenant SaaS</title>
      <dc:creator>Bytephase Technologies Pvt Ltd</dc:creator>
      <pubDate>Mon, 05 Oct 2026 09:50:52 +0000</pubDate>
      <link>https://dev.to/bytephase/database-per-tenant-in-laravel-6-lessons-from-running-a-multi-tenant-saas-50b4</link>
      <guid>https://dev.to/bytephase/database-per-tenant-in-laravel-6-lessons-from-running-a-multi-tenant-saas-50b4</guid>
      <description>&lt;p&gt;We build &lt;a href="https://bytephase.com/" rel="noopener noreferrer"&gt;BytePhase&lt;/a&gt;, &lt;a href="https://bytephase.com/" rel="noopener noreferrer"&gt;repair shop management software&lt;/a&gt; used by 2,000+ repair businesses in 32+ countries. Every customer account gets &lt;strong&gt;its own database&lt;/strong&gt;. Here's what that choice taught us. None of it is exotic, but most of it we learned the hard way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Laravel + &lt;a href="https://tenancyforlaravel.com/" rel="noopener noreferrer"&gt;stancl/tenancy&lt;/a&gt;&lt;/strong&gt;, database-per-tenant mode.&lt;/li&gt;
&lt;li&gt;One &lt;code&gt;central&lt;/code&gt; database holds tenant metadata, subscriptions and plans.&lt;/li&gt;
&lt;li&gt;Each tenant gets a &lt;code&gt;tenant_{id}&lt;/code&gt; database with ~120 tables: tickets, invoices, inventory, customers and so on.&lt;/li&gt;
&lt;li&gt;Tenants are identified by subdomain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pitch for database-per-tenant is isolation: a missing &lt;code&gt;WHERE tenant_id = ?&lt;/code&gt; can't leak one shop's customers into another shop's screen, because the other shop's rows aren't in the database at all. That holds. The cost moves elsewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Queued jobs have no tenant until you give them one
&lt;/h2&gt;

&lt;p&gt;An HTTP request finds its tenant from the subdomain. A queued job doesn't: by the time a worker picks it up, there's no request at all. The rule we now enforce everywhere:&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;tenancy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;tenantId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... the actual work&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass the tenant id into the job's constructor, initialise tenancy first in &lt;code&gt;handle()&lt;/code&gt;, and never rely on "whatever tenant was active when this was dispatched". Without this, a job can run against the wrong database, or the central one, and nothing tells you.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Cache keys are shared unless you make them not
&lt;/h2&gt;

&lt;p&gt;Redis doesn't know about your tenants. &lt;code&gt;Cache::remember('settings', ...)&lt;/code&gt; in tenant A, then the same key in tenant B, and B sees A's settings. stancl/tenancy can prefix cache tags per tenant, but any hand-rolled key (rate limiters, locks, &lt;code&gt;Cache::forever&lt;/code&gt; calls in a service class) has to include the tenant id. We treat a cache key without a tenant prefix as a bug in code review.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. "Tenant" is not always "business"
&lt;/h2&gt;

&lt;p&gt;Our customers asked for something database-per-tenant doesn't give you for free: &lt;strong&gt;several billing identities inside one account&lt;/strong&gt;. One owner might run two shops with different tax numbers, logos, invoice number series and bank details, while sharing staff and customers.&lt;/p&gt;

&lt;p&gt;So inside each tenant there's a second level, a &lt;em&gt;business&lt;/em&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The frontend sends an &lt;code&gt;X-Business&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;Middleware resolves it to the current business.&lt;/li&gt;
&lt;li&gt;A global Eloquent scope filters business-owned models (invoices, stock, jobs).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;business_id&lt;/code&gt; is stamped on create and &lt;strong&gt;can never change&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Documents always print with their &lt;em&gt;own&lt;/em&gt; business's identity and numbering, never the one in the current header.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lesson: decide early which tables are tenant-wide (customers, staff, masters) and which are per-business, and write it down. Retrofitting the scope later meant touching every query that assumed "one shop per database".&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Migrations run N times, so they must be boring
&lt;/h2&gt;

&lt;p&gt;A migration that takes 2 seconds takes 2 seconds × every tenant. Worse, one tenant with odd data can fail halfway through a batch. What works for us:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Schema migrations only. Settings and data rows go through idempotent seeders, never data migrations.&lt;/li&gt;
&lt;li&gt;Never edit a migration that has already run anywhere.&lt;/li&gt;
&lt;li&gt;Every migration must be safe to re-run on a tenant where it half-applied.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  5. Per-tenant settings need defaults that can roll forward
&lt;/h2&gt;

&lt;p&gt;New settings keys land in a definitions class with defaults. A seeder &lt;code&gt;ensure()&lt;/code&gt;s them on every existing tenant/business. Code always reads through a helper that falls back to the default. That way a new feature never crashes on an old tenant that hasn't been backfilled yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Isolation is a property you test, not one you assume
&lt;/h2&gt;

&lt;p&gt;Database-per-tenant makes cross-tenant leaks &lt;em&gt;rarer&lt;/em&gt;, not impossible. Shared caches, shared queues, file storage paths, and anything that touches the &lt;code&gt;central&lt;/code&gt; connection are all ways back in. We keep a short checklist for every feature:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does it run in a queue? → initialise tenancy.&lt;/li&gt;
&lt;li&gt;Does it cache? → tenant-prefixed key.&lt;/li&gt;
&lt;li&gt;Does it store files? → tenant-scoped path.&lt;/li&gt;
&lt;li&gt;Is it business-scoped? → global scope + immutable &lt;code&gt;business_id&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63ek7cy9ms2ywvyb1fy4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F63ek7cy9ms2ywvyb1fy4.png" alt="Tenant isolation checklist for a Laravel multi-tenant SaaS: initialise tenancy in queued jobs, tenant-prefixed cache keys, tenant-scoped file paths, immutable business_id" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;If you're building something similar, or you run a repair shop and want to see where all this ends up, the &lt;a href="https://bytephase.com/features/repair-ticket-management-software/" rel="noopener noreferrer"&gt;repair ticket workflow&lt;/a&gt; is the part of the product every one of those lessons touched. Happy to answer questions in the comments.&lt;/p&gt;

</description>
      <category>laravel</category>
      <category>saas</category>
      <category>php</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
