[{"content":"","date":"11 gennaio 2026","externalUrl":null,"permalink":"/series/architettura-di-un-saas-multi-tenant/","section":"Series","summary":"","title":"Architettura Di Un SaaS Multi-Tenant","type":"series"},{"content":"","date":"May 11, 2026","externalUrl":null,"permalink":"/en/categories/backend/","section":"Categories","summary":"","title":"Backend","type":"categories"},{"content":"","date":"May 11, 2026","externalUrl":null,"permalink":"/en/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"May 11, 2026","externalUrl":null,"permalink":"/en/tags/eloquent/","section":"Tags","summary":"","title":"Eloquent","type":"tags"},{"content":"","date":"May 11, 2026","externalUrl":null,"permalink":"/en/","section":"Francesco Caglioti","summary":"","title":"Francesco Caglioti","type":"page"},{"content":"","date":"May 11, 2026","externalUrl":null,"permalink":"/en/categories/laravel/","section":"Categories","summary":"","title":"Laravel","type":"categories"},{"content":"","date":"May 11, 2026","externalUrl":null,"permalink":"/en/tags/laravel/","section":"Tags","summary":"","title":"Laravel","type":"tags"},{"content":"","date":"May 11, 2026","externalUrl":null,"permalink":"/en/tags/multi-tenant/","section":"Tags","summary":"","title":"Multi-Tenant","type":"tags"},{"content":"","date":"May 11, 2026","externalUrl":null,"permalink":"/en/series/multi-tenant-saas-architecture/","section":"Series","summary":"","title":"Multi-Tenant SaaS Architecture","type":"series"},{"content":" The Project # I have just finished building a SaaS platform to help transport companies manage their services. This platform is a multi-tenant B2B system, meaning each customer company has its own space where it can manage its fleet, drivers, warehouses, and transports, without seeing the data and/or actions of other customers.\nWhen I started designing the platform, I didn\u0026rsquo;t know whether to use different databases for each customer or create a single shared one.\nUltimately, I decided to share a single database among all customers for two main reasons: ease of maintenance and cost savings once in production.\nHowever, I had to learn how to divide, isolate, and manage customer data in watertight compartments.\nIn this article, I share how I implemented this isolation with Laravel, without using external packages, what mistakes I made, and what I would do differently if I had to rewrite such a system from scratch.\nLogical Flow # Here is how data is isolated from the moment of the request until the database response:\nUSER REQUEST │ ▼ MIDDLEWARE (The Gatekeeper) ────────┐ │ │ │ 1. Verify Authentication │ 403 UNAUTHORIZED │ 2. Extract Tenant ID │ (If no access) │ └───────────────────► [ EXIT ] ▼ TENANT CONTEXT (Source of Truth) │ │ 3. Store ID in Singleton Instance │ ▼ ELOQUENT MODELS (The Workers) │ │ 4. Boot BelongsToTenant Trait │ 5. Apply GlobalScope Automatically │ ▼ DATABASE QUERY │ │ SELECT * FROM table WHERE tenant_id = [X] │ ▼ ISOLATED DATA RESPONSE (Success!) Shared Database: Why this choice? # The decision to use a shared database (Single-Database Multi-tenancy) was not just driven by cost. In a B2B system, the ease of database evolution is critical.\nAdvantage: Migrations are atomic. If I add a column to transports, I do it only once for all customers. Disadvantage: The \u0026ldquo;Noisy Neighbor\u0026rdquo; risk (one tenant saturating resources) is real, and isolation is logical, not physical. If I had opted for separate databases, I would have had perfect hardware isolation, but managing 500 different migrations (if I ever reach that many tenants) every time I release a feature would have become a full-time job.\nTesting Isolation (Seriously) # The most important part is, and always will be, writing tests for every new endpoint or function.\nFor example, am I creating an API endpoint to return monthly performance metrics? I add a specific test (beyond happy paths and edge cases) to verify that metrics for TenantA are not visible to TenantB.\nIt\u0026rsquo;s not just because I don\u0026rsquo;t trust my own work, but primarily to ensure that in the future I don\u0026rsquo;t make some development that breaks this compartmentalization; I don\u0026rsquo;t trust my future self to remember everything or not make mistakes.\nThis is an example in PhpUnit where I test an endpoint:\npublic function test_cross_tenant_isolation(): void { $tenantA = Tenant::factory()-\u0026gt;create(); $tenantB = Tenant::factory()-\u0026gt;create(); Product::factory()-\u0026gt;for($tenantA)-\u0026gt;create([\u0026#39;name\u0026#39; =\u0026gt; \u0026#39;Item A\u0026#39;]); Product::factory()-\u0026gt;for($tenantB)-\u0026gt;create([\u0026#39;name\u0026#39; =\u0026gt; \u0026#39;Item B\u0026#39;]); $response = $this-\u0026gt;actingAs($this-\u0026gt;userInTenant($tenantA)) -\u0026gt;getJson(\u0026#39;/api/v1/products\u0026#39;); $response-\u0026gt;assertOk(); $response-\u0026gt;assertJsonCount(1, \u0026#39;data\u0026#39;); $response-\u0026gt;assertJsonPath(\u0026#39;data.0.name\u0026#39;, \u0026#39;Item A\u0026#39;); } Without a test like this, I\u0026rsquo;m just \u0026ldquo;hoping\u0026rdquo; the GlobalScope is applied globally.\nGlobalScope and TenantContext # Speaking of GlobalScope, it\u0026rsquo;s a Laravel feature that allows you to automatically apply one or more where clauses to all queries assigned to a model. For our purpose, it\u0026rsquo;s a godsend.\nNow a key question remains: what will be my source of truth regarding the tenant ID to apply to the query?\nI decided to create a TenantContext class, a singleton object that maintains the tenant\u0026rsquo;s state for the entire duration of the request. It will be my source of truth. The context is applied to all incoming requests via a specific Middleware. This makes the system testable and independent of the authentication driver (web, API, or CLI).\nI created a BelongsToTenant trait that automates both reading and writing:\ntrait BelongsToTenant { public static function bootBelongsToTenant(): void { static::addGlobalScope(\u0026#39;tenant\u0026#39;, function (Builder $query) { $context = app(TenantContext::class); if ($context-\u0026gt;isSet()) { $query-\u0026gt;where(\u0026#39;tenant_id\u0026#39;, $context-\u0026gt;id()); } }); static::creating(function (Model $model) { $context = app(TenantContext::class); if ($context-\u0026gt;isSet() \u0026amp;\u0026amp; !$model-\u0026gt;tenant_id) { $model-\u0026gt;tenant_id = $context-\u0026gt;id(); } }); } } Every model representing a tenant entity (Customers, Vehicles, Transports, etc.) has this trait. This way, not only are queries filtered, but I don\u0026rsquo;t even have to remember to assign the tenant_id when saving a new object.\nThe EnsureTenantAccess Middleware # The middleware is the \u0026ldquo;bridge\u0026rdquo; that populates our TenantContext at the beginning of each request.\npublic function handle($request, Closure $next) { $user = $request-\u0026gt;user(); if (!$user || (!$user-\u0026gt;tenant_id \u0026amp;\u0026amp; !$user-\u0026gt;isSuperAdmin())) { abort(403, \u0026#39;No tenant associated\u0026#39;); } $tenantId = $user-\u0026gt;isSuperAdmin() ? session(\u0026#39;impersonate_tenant_id\u0026#39;) : $user-\u0026gt;tenant_id; if ($tenantId) { app(TenantContext::class)-\u0026gt;set($tenantId); $tenant = Tenant::find($tenantId); if ($tenant-\u0026gt;is_read_only \u0026amp;\u0026amp; $request-\u0026gt;isMethodSafe() === false) { abort(403, \u0026#39;Account in read-only mode\u0026#39;); } } return $next($request); } This middleware is applied to all routes operating with a tenant\u0026rsquo;s data. Once passed, the currently logged-in user is \u0026ldquo;locked\u0026rdquo; into that tenant\u0026rsquo;s context and the GlobalScope knows how to act.\nThe Super Admin Case and Impersonation # A B2B system cannot function without a support service. The super_admin must be able to \u0026ldquo;enter\u0026rdquo; a customer\u0026rsquo;s account to diagnose problems, without their data mixing with the customer\u0026rsquo;s.\nThe solution I adopted is impersonation:\nThe Super Admin does not have a fixed tenant_id. Via an administration dashboard, they choose which Tenant (Customer) to assist/verify. We save the tenant ID in the session (impersonate_tenant_id). The middleware reads from the session and \u0026ldquo;pretends\u0026rdquo; the Super Admin belongs to that tenant for the duration of the navigation. In support cases, this allows us to have the exact same views as a customer.\nThe is_read_only Flag # A flag that proved useful is is_read_only on the tenants table.\nWhen a tenant is in read-only mode, all POST, PUT, PATCH, and DELETE requests return HTTP 403.\nThis serves me for:\nBlocking a tenant for payment reasons Performing maintenance without write risks Preventing changes during investigations The EnsureTenantAccess middleware checks this flag and blocks writes automatically. No logic scattered in controllers, everything is centralized.\nAnti-Patterns I\u0026rsquo;ve Learned to Avoid # Manual tenant_id assignment: If I did it manually, I\u0026rsquo;d eventually forget (as happened more than once). This is where tests and BelongsToTenant come in handy. Unique index without scope: Almost all indices I create on entities will almost always be verified in combination with the tenant_id. Using incremental IDs: For tenantId, I prefer using UUIDs. It prevents someone from \u0026ldquo;guessing\u0026rdquo; the ID of a different customer. Conclusion # A multi-tenant SaaS built with Laravel is not fundamentally a matter of code, but of \u0026ldquo;trust in the system you\u0026rsquo;ve built\u0026rdquo; and \u0026ldquo;lack of trust in people.\u0026rdquo; The single DB solution is the most balanced for most products similar to this one. This solution offers maximum peace of mind thanks to GlobalScope Automation, along with the speed of development, release, and maintenance that could not be achieved with per-tenant databases.\nTo delve into this aspect, if I were to redo everything from scratch, I wouldn\u0026rsquo;t change the basic structure I established; rather, I would focus more on creating extreme tests right from the start.\nLaravel offers excellent documentation on how to use GlobalScope, but you can also use packages like spatie/laravel-multitenancy to create multi-tenant applications. The most important aspect of this post is testing with your own implementation so you have maximum control over the code and how it\u0026rsquo;s written (with its drawbacks).\nFor example, implementing the super-admin would not have been immediately possible with external packages.\n","date":"May 11, 2026","externalUrl":null,"permalink":"/en/article/saas-multi-tenant/saas-multi-tenant-laravel/","section":"Blog","summary":"","title":"Multi-Tenant with Laravel: Data Isolation and Global Scope","type":"article"},{"content":"","date":"May 11, 2026","externalUrl":null,"permalink":"/en/tags/postgresql/","section":"Tags","summary":"","title":"Postgresql","type":"tags"},{"content":"","date":"May 11, 2026","externalUrl":null,"permalink":"/en/categories/saas/","section":"Categories","summary":"","title":"Saas","type":"categories"},{"content":"","date":"May 11, 2026","externalUrl":null,"permalink":"/en/tags/saas/","section":"Tags","summary":"","title":"Saas","type":"tags"},{"content":"","date":"May 11, 2026","externalUrl":null,"permalink":"/en/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","date":"May 11, 2026","externalUrl":null,"permalink":"/en/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"September 27, 2025","externalUrl":null,"permalink":"/en/categories/cv/","section":"Categories","summary":"","title":"Cv","type":"categories"},{"content":" Hi, I\u0026rsquo;m Francesco # Backend Engineer based in Milan, building distributed APIs and systems for Iliad Italia.\nWhat I do all day:\nRESTful APIs with Symfony and ApiPlatform Containerization and orchestration with Docker and Kubernetes Performance tuning and code quality Outside code: I manage a homelab with Proxmox, self-host everything I can, and try to spend as much time as possible in the mountains when the screen becomes too big.\n","date":"September 27, 2025","externalUrl":null,"permalink":"/en/cv/","section":"Francesco Caglioti","summary":"","title":"Francesco Caglioti","type":"page"},{"content":"","date":"September 24, 2025","externalUrl":null,"permalink":"/en/tags/cloudflare/","section":"Tags","summary":"","title":"Cloudflare","type":"tags"},{"content":"","date":"September 24, 2025","externalUrl":null,"permalink":"/en/categories/homelab/","section":"Categories","summary":"","title":"Homelab","type":"categories"},{"content":"","date":"September 24, 2025","externalUrl":null,"permalink":"/en/tags/homelab/","section":"Tags","summary":"","title":"Homelab","type":"tags"},{"content":"","date":"September 24, 2025","externalUrl":null,"permalink":"/en/tags/nginx/","section":"Tags","summary":"","title":"Nginx","type":"tags"},{"content":"","date":"September 24, 2025","externalUrl":null,"permalink":"/en/tags/proxmox/","section":"Tags","summary":"","title":"Proxmox","type":"tags"},{"content":"","date":"September 24, 2025","externalUrl":null,"permalink":"/en/tags/self-hosting/","section":"Tags","summary":"","title":"Self-Hosting","type":"tags"},{"content":"","date":"September 24, 2025","externalUrl":null,"permalink":"/en/categories/tailscale/","section":"Categories","summary":"","title":"Tailscale","type":"categories"},{"content":"","date":"September 24, 2025","externalUrl":null,"permalink":"/en/tags/tailscale/","section":"Tags","summary":"","title":"Tailscale","type":"tags"},{"content":"If you run a self-hosted HomeLab, you know the dilemma: expose everything to the internet with Cloudflare Tunnel or stay isolated on your local network?\nIt took me some months of trial and error to figure out how to balance security and accessibility. The solution? Tailscale VPN.\nIn this guide, I\u0026rsquo;ll show you exactly how I configured Tailscale on my Proxmox HomeLab to securely access all my services remotely, without exposing them publicly.\nHomeLab # I run a HomeLab with very basic functionality, for example:\nHomeAssistant Paperless Trilium Notes I\u0026rsquo;ve always accessed these services through Cloudflare Tunnel and never had a bad experience using it, but it always bothered me to publish all my services to the open internet and make them accessible to anyone.\nSo over time I considered using a VPN, so that only myself and the people I grant access to could use these services. This decision comes with some drawbacks, like not being able to share documents from Paperless via link, or losing some of the \u0026ldquo;away from zone\u0026rdquo; features in HomeAssistant — nothing that a hybrid solution can\u0026rsquo;t mitigate.\nTo give some context on my HomeLab structure, I have a MiniPC running Proxmox. Inside it there\u0026rsquo;s an LXC container for the Cloudflare Tunnel, which up to now (together with the Cloudflare dashboard configuration panel) has acted as a Reverse Proxy for the services I wanted available outside my network.\nTailscale subscription # After reading on subreddits and watching YouTube (bless YouTube), I came across several people using Tailscale, a VPN provider with an excellent free tier for hobbyists, based on WireGuard. At that point I created an account, connected my PC and phone for the initial setup, and started planning what I would need to configure from there.\nNginx Proxy Manager # I decided to use a new LXC container with Nginx Proxy Manager for the Reverse Proxy role. Once installed, I just had to configure my SSL certificate under \u0026ldquo;SSL Certificates\u0026rdquo; using Cloudflare as the provider.\nCloudflare # To use Cloudflare as a Let\u0026rsquo;s Encrypt provider you need to generate a token from the Cloudflare dashboard, going to Manage Account \u0026gt; API Tokens. From there you create a new token with the \u0026ldquo;Edit DNS Zone\u0026rdquo; permission and save it for later.\nAlso, while you\u0026rsquo;re there, go to your domain panel under DNS and add a new entry configured for the local network. Nginx configuration # Back on Nginx, you can finalize the SSL certificate configuration and add your first host. Go to \u0026ldquo;Add SSL Certificate\u0026rdquo; and select Let\u0026rsquo;s Encrypt.\nThen enter your domain, check \u0026ldquo;Use DNS Challenge\u0026rdquo; and configure it for your provider — in this case Cloudflare. The last bit of Nginx configuration, to make sure things work going forward, is to register a new host.\nYou can do that directly under \u0026ldquo;Hosts \u0026gt; Proxy Hosts\u0026rdquo; and configure the new proxy.\nWarning! Make sure to use the same domain you entered earlier. Once the new proxy is configured, try connecting directly with the new URL and check that you can reach your service.\nFor any other questions on configuring Nginx, here\u0026rsquo;s a video by Wolfgang who explains the basics very well, including how to get it running with DuckDNS.\nTailscale configuration # I had some trouble accessing my local network through Tailscale, because for some reason I was convinced that simply configuring a host would be enough — in the case of Nginx — to immediately reach the surrounding network. Unfortunately I learned the hard way that\u0026rsquo;s not the case, but let\u0026rsquo;s go step by step.\nFirst, you need to install the Tailscale add-on on an LXC container. In my case I decided to install it in the same container as Nginx for convenience, but you can create a dedicated one just for this.\nOnce that\u0026rsquo;s done, just keep following the documentation to get it working as a regular Tailscale node. But that\u0026rsquo;s not what we want — we want this node to act as a \u0026ldquo;bridge\u0026rdquo;, exposing a subnet to the rest of the devices connected to the VPN.\nTo make it a bridge with the rest of the network, you need to take a couple of steps. Here are the links:\nSubnet Routes Exit Nodes To explain step by step what I did:\nEnable IP forwarding Advertise the subnets I\u0026rsquo;m interested in to Tailscale Approve those subnets from the Tailscale control panel Configure the Tailscale client to allow connections to other nodes on the local network Mark the Tailscale client as an \u0026ldquo;exit node\u0026rdquo; In practice, these two wiki pages let me complete exactly the configuration I wanted: remote access to my home network as if I\u0026rsquo;d never left home.\nFinal configuration # As mentioned above, I have some services that should NEVER be directly accessible from the open internet, like Nginx, but others that to function properly need a properly configured tunnel — take HomeAssistant for example.\nSo I decided to apply a hybrid rule for my needs, leaving some containers protected behind the VPN and others reachable through the Cloudflare Tunnel. Some examples:\nVPN Nginx Vikunja Trilium Tunnel HomeAssistant Paperless Conclusion # I think this was a great experiment to learn how to use Tailscale, and I\u0026rsquo;ll definitely keep using it (I already have a few ideas with n8n in mind). I also believe it should be the default choice in many cases when deciding to self-host services at home.\n","date":"September 24, 2025","externalUrl":null,"permalink":"/en/article/tailscale/","section":"Blog","summary":"","title":"Tailscale VPN: Complete HomeLab Setup Guide","type":"article"},{"content":"","date":"September 24, 2025","externalUrl":null,"permalink":"/en/categories/vpn/","section":"Categories","summary":"","title":"Vpn","type":"categories"},{"content":"","date":"September 24, 2025","externalUrl":null,"permalink":"/en/tags/vpn/","section":"Tags","summary":"","title":"Vpn","type":"tags"},{"content":"","date":"September 24, 2025","externalUrl":null,"permalink":"/en/tags/wireguard/","section":"Tags","summary":"","title":"Wireguard","type":"tags"},{"content":"I\u0026rsquo;m always interested in discussing new ideas, collaborations, or interesting technical challenges. If you have a project in mind, feel free to contact me.\n","date":"January 1, 2025","externalUrl":null,"permalink":"/en/projects/","section":"Francesco Caglioti","summary":"","title":"Projects","type":"page"},{"content":" CONTENT DIRECTORY # OVERVIEW # Bilingual Markdown content (Italian default + English). Article bundles + standalone pages.\nSTRUCTURE # content/ ├── Article/ # Article bundles (capitalized - non-standard) │ ├── _index.md # List page (Italian) │ ├── _index.en.md # List page (English) │ └── \u0026lt;Name\u0026gt;/ │ ├── index.md # Article (Italian) │ └── index.en.md # Article (English) ├── cv.md / cv.en.md # CV page (bilingual pair) ├── projects.md / projects.en.md # Projects page └── CONTENT_GUIDELINES.md # Content writing guidelines WHERE TO LOOK # Task Location Notes Add new article content/Article/\u0026lt;Name\u0026gt;/index.md + .en.md Create both language versions Edit existing Match filename suffix .md = Italian, .en.md = English Content guidelines content/CONTENT_GUIDELINES.md Writing standards CONVENTIONS # Bilingual suffix: .en.md for English, plain .md for Italian (default) Leaf bundles: Each article in its own directory with index.md Capitalized \u0026ldquo;Article\u0026rdquo;: Non-standard (Hugo convention is lowercase article/) Mixed structure: Root-level pages (cv.md) + bundled articles ANTI-PATTERNS # ❌ Single-language articles (always create both .md + .en.md) ❌ Flat files in Article/ (must be in subdirectories as bundles) ❌ Inconsistent naming (use TitleCase for article directories) NOTES # URL paths will include /Article/ (capitalized) due to directory name Root-level pages (cv.md, projects.md) render at /cv, /projects ","externalUrl":null,"permalink":"/agents/","section":"Francesco Caglioti","summary":"","title":"","type":"page"},{"content":"","externalUrl":null,"permalink":"/en/article/","section":"Blog","summary":"","title":"Blog","type":"article"}]