One application serves your own site and every team's site. This guide covers how a request is told apart, what team() answers in each case, which routes a team's domain has, and how to test code that runs there. Read it before writing anything that runs on a team's domain.
Two kinds of domain
The host of APP_URL is the application's own domain. Every other host is read as the address of a team's site.
| Domain | What answers |
|---|---|
The host of APP_URL |
Your home page, the feature pages, the help centre, /admin, /app and /team, from routes/web.php, routes/admin.php, routes/app.php and routes/team.php. |
| Any other host | That team's site, from routes/site.php: its pages, its forms, its files and its /team workspace. |
| Both | Sign-in, registration and password recovery, from routes/auth.php, every route a package registers, and the routes of routes/site.php below, which are not bound to any domain. |
In .env.testing, APP_URL=http://localhost, so a test request without a host of its own is a request to the application's domain.
How a host becomes a team
On a team's domain every request passes two middlewares, websiteExists and teamConfig:
- A host starting with
www.is redirected, with a temporary redirect, to the same address without the leadingwww.. The scheme, the path and the query are kept:https://www.example.com/blog?page=2goes tohttps://example.com/blog?page=2. AGETorHEADgets a 302. Any other method gets a 307, which keeps the method and the body, so a form posted to awww.address still arrives as aPOSTand is saved. - The host is compared with the
urlof every team. The team whoseurlis the host is the site's team. - A host that matches no team is redirected to
APP_URLwith?error=site_not_configured&url={host}. teamConfigswaps the application's reCAPTCHA keys for the team's own for the rest of the request.
App\Classes\DetectWebsite does the matching, and Team::current() asks it. It answers false on the application's own domain, and the team, or null, anywhere else.
What team() returns
team() is defined in app/Helpers/builder.php, which loads before app/Helpers/teams.php, so it replaces the Teams helper. It answers, in order:
- The team whose site is being visited, when the request is on a team's domain.
- The team the signed-in person is working in — the one in the
team_idcookie, or their current team — otherwise, exactly as in Teams.
So the same code reads the right team in both places: a page rendering on mystore.example.com gets that store's team, even for a visitor who belongs to other teams, and a screen under /team on your own domain gets the team the person picked.
Two escapes exist for code without a real host. In the console, a team put in the session under current_team is used as is. Anywhere, a team id put in the session under force_team pins that team to every request:
session(['force_team' => $team->id]);
The routes of a team's domain
routes/site.php, in the order they are matched:
| Route | What it does |
|---|---|
GET /media/{path} |
Serves a file of the team's library, when MULTIMEDIA_ENABLE_MASK is on. |
POST /form/{slug} |
Receives a form of the site. See Forms and entries. |
GET /app, GET /admin |
Redirect to /team. |
/team/... |
The team workspace, the same routes/team.php as on your domain, named team-admin.* instead of team.*. |
GET /api/dns-validation |
Answers {"status":"ok"}, up to 60 requests a minute. The domain checker calls it to know the DNS reaches you. |
Any other GET |
The page resolver, registered as the fallback route. |
These routes are registered without a domain, so the ones your own domain does not already answer also answer on the host of APP_URL: /media/..., /form/{slug}, /api/dns-validation and the page resolver. There team() is the signed-in person's current team, so a signed-in member who opens an unknown path on your domain is served the page of that team at that path, if it has one, and a visitor who is not signed in gets a 404, or an error for /media/....
Why the page resolver never hides another route
The page resolver is registered with Route::fallback(). Laravel tries a fallback route only after every other route failed to match, whatever the order they were registered in. A package route such as /account/... from the billing package, or a route you add anywhere, is always matched before the resolver, on every domain. A route you add for a team's site goes in routes/site.php.
How a path becomes a page
App\Livewire\TeamWeb\Resolver serves the fallback route:
| Path | Page served |
|---|---|
/ |
The active page with the Home template type. With no such page, a 404. |
| A path equal to a page's slug | That page. Layouts are left out, so a layout's slug answers 404. |
A path that fits a page's slug pattern, like products/{slug} or categories/products/{?slug} |
That page, with the captured values handed to its template type. Only pages with a template type are tried. See Template types and categories. |
| Anything else | A 404. |
Only active pages are considered, and never a layout: a layout is not the home page and is not tried against a slug pattern either. The page is rendered inside its layout by resources/views/components/project/layouts/team-web.blade.php, which adds the site's favicon, its language and the Tailwind browser script. What a page receives is in Pages and layouts.
While a page renders
The page resolver runs behind the debug middleware, App\Http\Middleware\EnableDebugMode. In the local environment it changes nothing. In every other environment, for the request that renders a page, it sends the log to the null channel, so a site's errors are never logged, and marks the request as read-only. It never turns app.debug on: on a team's domain debug is off for everyone, a signed-in sudo user included, and on your own domain it stays what APP_DEBUG says.
On a read-only request every model that extends App\Models\BaseModel uses the mysql_readonly connection, App\Database\ReadOnlyMySqlConnection. It reads as usual and refuses every insert, update and delete, and any statement that creates, alters, drops or truncates a table. A page's code can read the team's records; a write from it fails.
When a page's code fails
Outside the local environment, what a failing page shows depends on the team's Debug mode, a toggle in Settings › General information, on until the team switches it off. It is read as $team->debug_mode.
With it on, when the Blade code of a page throws an error, the site answers with the page's error screen instead of the page, with status 500. BuilderServiceProvider::boot() draws it from resources/views/components/errors.blade.php. It shows:
- the error message;
- the line that failed, counted in the page's compiled code;
- the page's title;
- the page's code as Blade compiled it to PHP.
That is what the team's owner sees when their page breaks, and what a visitor of the site sees too. An error that did not come from a page's compiled code is not drawn on this screen.
With it off, and for any error outside a page's code, a visitor or a team member gets the generic resources/views/errors/500.blade.php: "Something went wrong on our side", with no message, line or code, and a button back to the home page. A missing page always answers with the 404 screen, whatever the toggle says.
A sudo user gets the same screens as anybody else: outside the local environment the framework's own error page is never shown on a team's domain. The toggle only chooses the screen: the page's code still runs read-only and its errors are still not logged.
In the local environment none of this applies: the error is handled as any other, by the APP_DEBUG of your .env.
Test it
Send the request with the team's host:
$team->update(['url' => 'mystore.test']);
$this->withServerVariables(['HTTP_HOST' => 'mystore.test'])
->get('/about')
->assertOk();
When the domain is not what the test is about, pin the team with the session instead:
$this->withSession(['force_team' => $team->id])->get('/about')->assertOk();