Version 1.0.0

Micro-SaaS Starter for PHP — Multi-Tenant Skeleton

Product price:
$0.00 USD
0Composer dependencies
52Test assertions, all passing
2,329Lines of PHP across 23 files
8.1+PHP version floor

About Micro-SaaS Starter for PHP — Multi-Tenant Skeleton

A multi-tenant skeleton in plain PHP, with the tenancy done in one place.

Multi-tenancy fails in one specific way. Someone adds a feature, writes a query, and forgets WHERE tenant_id = ?. It passes review, because the reviewer is reading the feature. It passes testing, because in testing there is one tenant. It is found later, by a customer, looking at another customer’s data.

This starter’s answer is that feature code never writes the condition at all. Here is the entire read path for a tenant’s projects:

// app/Services/Projects.php
public static function forTenant(Context $context): array
{
    return $context->db->all('projects', '', [], 'ORDER BY id DESC');
}

No tenant id is passed in, so there is no wrong integer to pass. $context->db is a TenantConnection — a PDO handle pinned to one tenant when it is built — and it puts tenant_id = :__tenant in front of every condition itself.

What the scoping class enforces

Seven rules, all at runtime, all in app/TenantConnection.php:

  1. Only tables listed in TENANT_TABLES are reachable. A typo, or a table nobody registered, throws — rather than quietly returning every row in the system, which is the failure mode this whole class exists to prevent.
  2. Every WHERE begins with the tenant condition, added by the class.
  3. A caller’s condition may not mention tenant_id. There is no honest reason to write it, and it is how an override would arrive.
  4. insert() sets tenant_id itself and rejects a row that supplies one.
  5. update() and delete() match on id and tenant, so another tenant’s id affects zero rows instead of theirs.
  6. ORDER BY and LIMIT fragments must match a strict pattern — column names and digits, nothing else.
  7. Every row returned is checked, and a foreign tenant_id throws TenantLeak rather than reaching the caller.

Rule 7 is unreachable if rules 1–6 hold. It is in there anyway, because the cost of being wrong about that is another customer’s data on someone’s screen.

Adding your own tenant-owned table is a tenant_id column and one line in TENANT_TABLES. That is the whole ceremony.

The unscoped queries, all five of them

Three moments genuinely happen before a tenant is known: logging in, registering, and clicking an invite link. app/SystemGateway.php serves exactly those, in five named methods across 92 lines. It accepts no SQL from its callers and returns no list of anything. Read it once and you have seen every unscoped query in the project — which is the point of putting them all in one short file.

What else is in it

Auth. password_hash and password_verify with PASSWORD_DEFAULT, re-hashed on login when PHP’s default cost moves. session_regenerate_id(true) on login, so a session id planted before login is not the one that ends up authenticated. Cookies are HttpOnly and SameSite=Lax. A failed login gives the same message whether the address exists or not, and an unknown address still runs one password_verify against a dummy hash so the timing does not answer the question either.

Roles. Owner and member. Owners invite, cancel invites and remove people. The rule lives in Context::requireOwner(), which every owner-only service method calls first; templates separately use isOwner() to decide what to draw. Both, deliberately. A hidden button is not a permission, and the test suite posts as a member to prove the service refuses it with the button out of the picture.

Invites. A 32-byte random token, stored only as its SHA-256 hash, so the database file alone holds nothing usable. Valid once and for 48 hours by default. Acceptance claims the invite with an accepted_at IS NULL condition inside a transaction, so two clicks arriving together cannot both create a member. There is no mail server here — the link is shown once and appended to data/invites.log, and swapping that for your mailer is a five-line method.

Plans and seats. Free, Team and Business ship seeded, at 2, 10 and 50 seats. A seat is a member who exists plus an invite nobody has used yet; without counting pending invites, an owner on a two-seat plan can send ten and you find out from the bill. The limit is checked when the invite is created and re-checked when it is accepted, because an invite sent last week does not get to exceed today’s plan. Going over raises SeatLimitReached, which the front controller turns into HTTP 402 carrying the plan name and the real numbers.

Storage. SQLite through PDO, prepared statements throughout, schema created by php bin/migrate.php. Five tables. One file you can delete and re-migrate.

It ships with its own test suite

php bin/test.php

52 assertions, no framework. The suite builds a throwaway database, migrates it, creates two tenants with data, and then drives the same classes the web front end uses — nothing in it hand-writes SQL, because a check that writes its own query has not tested the application. Twenty of the assertions are tenant isolation: tenant A trying to read, list, count, update and delete tenant B’s rows, plus every guard listed above, plus a project whose name is Globex ' OR '1'='1 that stays an ordinary string because it is bound. Then login with the right and wrong password, single use and expiry on invites, owner-only actions refused for a member, and the seat limit blocking the seat that goes over.

It exits non-zero on failure, so you can put it in CI on day one.

What it does not do

The list is short and it is deliberate:

no billing · no email delivery · no OAuth or SSO · no JSON API · no admin panel · no queue or background jobs · no password reset · no file uploads · no rate limiting · no audit log

Billing is the one with a marked seam. plans.price_cents is already there, tenants.plan_id is the single value a successful payment has to change, SeatLimitReached is its own exception class so a billing layer can catch it and offer an upgrade instead of an error page, and config.example.php has a commented block for your keys. The provider code is not written, and nothing here pretends otherwise — integrating one is where you decide what happens to a tenant whose card fails, and a stub of that decision would be worse than no decision.

Two more things your deployment owns, not this zip: HTTPS, and rate limiting on login and invite acceptance. Rate limiting needs state shared across processes, which is a decision about where that state lives.

Where you would reach for something else

If you want an admin panel that reads across tenants, this is the wrong shape: there is deliberately no code path that reads two tenants at once. If you need one person in several tenants under one email address, users.email is unique install-wide here and changing that means a different unique index and a tenant selector at login. If you want Eloquent, migrations with a version table, queues and a package ecosystem, use Laravel with a tenancy package — this exists for the case where you want to open every file and know what it does.

Requirements

PHP 8.1 or newer with pdo_sqlite, session and mbstring. No Composer, no vendor/, no build step, nothing fetched from a CDN at runtime, no JavaScript shipped at all. Built and tested on PHP 8.3.33 with SQLite 3.53.2; written against 8.1 language features and nothing newer.

MIT licensed. 2,329 lines of PHP across 23 files, small enough that reading all of it before you build on it is a reasonable afternoon.

Micro-SaaS Starter for PHP — Multi-Tenant Skeleton

Tenancy, auth, roles, invites and seat limits in plain PHP 8.1 and SQLite. No framework, no Composer, no build step.

0Composer dependencies
52Test assertions, all passing
2,329Lines of PHP across 23 files
8.1+PHP version floor

Key capabilities

Scoping that code cannot forget

Services ask for 'projects', never for 'projects belonging to tenant 7'. TenantConnection pins a PDO handle to one tenant and adds the condition itself, refuses tables it does not know, refuses a caller condition that mentions tenant_id, and throws if a foreign row ever comes back.

Exactly five unscoped queries

Login, registration and invite acceptance genuinely happen before a tenant is known. SystemGateway serves those three moments in five named methods across 92 lines, takes no SQL from callers, and returns no lists. Read it once and you have seen every unscoped query in the project.

Invites without a mail server

A 32-byte token, stored only as its SHA-256 hash, valid once and for 48 hours. Acceptance claims it with an accepted_at IS NULL condition inside a transaction, so two simultaneous clicks cannot both create a member. The link is printed once and appended to data/invites.log.

Seat limits that actually stop

A seat is a member plus an unused invite, so ten invites on a two-seat plan cannot become ten members. The limit is checked when the invite is sent and re-checked when it is accepted, and raises SeatLimitReached, rendered as HTTP 402 with the plan name and the real numbers.

Security you can grep for

Prepared statements everywhere with no concatenated user input; one escaping helper and no raw() counterpart; session_regenerate_id on login; HttpOnly SameSite cookies; identical login failure for unknown email and wrong password; no secret in the repo.

A stated boundary, not an apology

No billing, no email delivery, no OAuth, no API, no admin panel, no queue, no password reset. The README says so and marks the one seam where Stripe or Paddle plugs in: plans.price_cents exists, tenants.plan_id is the value a payment changes, and config has a commented block for the keys.

Questions & Answers

Is this a framework?

No. It is an application skeleton of 2,329 lines of PHP across 23 files, with no dependency to install and nothing to compile. You are meant to read all of it, then delete the demo projects table and build your own thing on the tenancy and auth underneath.

Why SQLite?

Because a starter you cannot run in thirty seconds is a starter you do not evaluate. One file, no server, delete it and re-migrate. Its limit is concurrent writers. When you outgrow that, the DSN is in app/Db.php and the schema in app/Migration.php is ordinary SQL that ports to MySQL or Postgres with type changes; TenantConnection does not care which it is talking to.

What stops a future developer from bypassing the scoping?

Nothing stops someone determined from opening a raw PDO handle — no library can. What this prevents is the accident. Feature code has no tenant id to pass, unregistered tables throw, conditions mentioning tenant_id are rejected, inserts carrying their own tenant_id are rejected, and any row with a foreign tenant_id that somehow arrives throws TenantLeak instead of being returned.

Can one person belong to two tenants?

Not with the same email address. users.email is unique across the whole install so that login never has to ask which account you meant. This is a real constraint and the README states it. Changing it means making the unique index (tenant_id, email) and adding a tenant selector to login — contained, but a change.

How do I add billing?

You write it. This product deliberately contains no payment code, because integrating a provider is where you decide what happens to a tenant whose card fails, and a stub of that decision would be worse than none. What is here: plans.price_cents seeded, tenants.plan_id as the single value a successful payment changes, SeatLimitReached as its own exception class so a billing layer can catch it and offer an upgrade, and a commented key block in config.example.php.

Does it send email?

No. Invite links go to data/invites.log and to the screen. Team::logInvite() is a five-line method; replacing it with your mailer is the whole job.

Is it production-ready?

That depends on what you are putting in production, so here is the checkable version instead. Passwords use password_hash; every query is a prepared statement; every template value is escaped; CSRF is verified in one place for every POST; the session id is regenerated on login. Two things it does not do and your deployment must: HTTPS, and rate limiting on login and invite acceptance, which needs shared state across processes and therefore a decision this zip cannot make for you.

Which PHP versions?

Written against PHP 8.1 language features and nothing newer. It was built and tested on PHP 8.3.33 with SQLite 3.53.2.

Tutorials

Install

You need PHP 8.1 or newer with pdo_sqlite, session and mbstring. There is no composer.json and no vendor/ directory to create.

unzip micro-saas-starter-php.zip
cd micro-saas-starter-php
cp config.example.php config.php
php bin/migrate.php
php bin/test.php
php -S localhost:8000 -t public

bin/migrate.php creates data/app.sqlite, the five tables and the three seeded plans, and prints what it did. bin/test.php builds its own throwaway database and prints 52 assertions ending in OK. Then open http://localhost:8000.

See the tenancy work

Register an account: you become the owner of a new tenant. Add a project. Now register a second account in a private window and confirm the first tenant's project is not there and its id is not reachable. That is the same check bin/test.php makes twenty times.

Invite someone

As an owner, go to Team and invite an address. There is no mail server: the link appears in the banner once and is appended to data/invites.log. Open it in another browser profile to join as a member. Open it a second time and it is refused with HTTP 410.

Add your own tenant-owned table

Add the table to app/Migration.php with a tenant_id column, then add its name to TenantConnection::TENANT_TABLES. That is the whole ceremony — from then on $context->db->all('your_table') is scoped, and a table you forgot to register throws instead of returning everyone's rows.

Deploy

Point the document root at public/ and keep app/, templates/, bin/, data/ and config.php out of the web root. public/.htaccess handles Apache routing; the nginx equivalent is one try_files line, in the README. Set cookie_secure to true once you are on HTTPS.

Support

What support means for this product, stated so it is not a surprise:

  • The code is the documentation. The README covers install, deployment, the seven scoping rules, the security measures and the stated non-goals. The tenancy and gateway classes are commented at length because they are the part you are buying.
  • Bug reports are welcome — a case where the tenancy, auth, invite or seat-limit behaviour differs from what the README describes. The most useful report is a failing assertion added to bin/test.php.
  • What is not support: writing your Stripe integration, your mailer, your OAuth, or your feature code. Those are the stated non-goals, and the boundary is what keeps this small enough to read.
  • Licence: MIT, included as LICENSE. Use it commercially, modify it, ship it inside a closed-source product.
  • There is no ticket queue and no response-time commitment, because neither has been measured. Nothing here depends on a service that can be switched off.
SupportIncluded
Money-backGuaranteed
DocumentationFull guide
Easy installOne-click
Original100% authentic
$0.00USD