Loading...

This is taking longer than expected.

The public site

How a request on a team's domain finds its team and its page, what team() returns on each domain, and the order the routes are registered in.

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:

  1. A host starting with www. is redirected, with a temporary redirect, to the same address without the leading www.. The scheme, the path and the query are kept: https://www.example.com/blog?page=2 goes to https://example.com/blog?page=2. A GET or HEAD gets a 302. Any other method gets a 307, which keeps the method and the body, so a form posted to a www. address still arrives as a POST and is saved.
  2. The host is compared with the url of every team. The team whose url is the host is the site's team.
  3. A host that matches no team is redirected to APP_URL with ?error=site_not_configured&url={host}.
  4. teamConfig swaps 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:

  1. The team whose site is being visited, when the request is on a team's domain.
  2. The team the signed-in person is working in — the one in the team_id cookie, 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();