A Laravel application that renders React pages through Inertia. It has no
database and no credentials: every page is built from markdown in
resources/blog, JSON in resources/data, and the public GitHub API.
/ homepage
/download /ios /pricing /faq /about /security
/features /features/{slug}
/databases /{database}-client /{database}-gui
/compare /compare/{slug}
/integrations /integrations/{slug} from resources/data/integrations.json; detail pages in English only
/privacy /terms /refund-policy
/brand English only: resources/data/legal/en/brand.md, files in public/images/brand
/blog /blog/{slug} markdown in resources/blog
/blog/feed.xml the English posts as Atom, linked from the head of blog pages
/vi/… every page above, in Vietnamese, where it exists
/robots.txt /sitemap.xml
/.well-known/security.txt RFC 9116; `SecurityTxtController::EXPIRES` is moved by hand
Pages resolve by convention from resources/js/pages. The shared
resolve-page.ts resolver loads the selected language's UI catalog and asset
text before rendering on the client or through SSR. English is in the entry
bundle; every other language's catalog is a chunk the root template sends with
the document.
English is at the root. Vietnamese, Spanish, German, French, Japanese, Brazilian
Portuguese, Simplified Chinese, Korean, Traditional Chinese, Italian and Indonesian
use the prefixes in resources/data/locales.json, with the same slugs. The
locale comes from the URL alone: no cookie, no session, no Accept-Language,
no IP lookup, no Vary on language and no redirect between languages. A
crawler or a reader reaches each language by its URL.
resources/data/locales.json the allowlist: code, native name, prefix, hreflang, og, Intl tag
routes/localized.php every page, declared once
routes/web.php mounts localized.php once per locale:
/… names landing.* middleware locale:en, page
/vi/… names vi.landing.* middleware locale:vi, page
locale:{code} (SetLocale) sets the application locale from the route group.
page (EnsurePageRenders) asks App\Support\Seo\PageRegistry whether the
page renders in that locale. A page renders in a locale when its content exists
there (resources/data/content/{locale}/…, resources/data/legal/{locale}/…,
resources/blog/vi/…); until then the URL answers a branded 404 that links the
version that does exist. So /vi/blog/{slug} for an English-only release post
is a 404 offering the English post, never the English body under Vietnamese
chrome. A page with content in no locale is no page at all: the registry does
not know it and the route answers 404.
The same registry feeds the shared seo prop (robots, canonical, hreflang,
og:locale, OG card), the localization.switcher prop and the sitemap, so they
cannot disagree. A page can render in a locale without being indexed there:
Every localized blog index lists English posts, so its content file sets seo.indexable: false
and it carries noindex, follow with no canonical and no alternates.
Errors render the Error page in the locale of the path (404 and 410 always,
500 and 503 with debug off), with noindex, follow.
resources/views/errors/{500,503}.blade.php are static fallbacks that need no
build.
UI strings are typed catalogs in resources/js/i18n/messages/{locale}/; page
copy is per-locale JSON; server-rendered strings are in lang/{locale}/. The
CLAUDE.md "Languages" section has the rules for writing them.
A reader who likely reads another language this page exists in gets one bar
above the header offering it, in that language ("Đọc trang này bằng tiếng
Việt →"), in place of the license banner (TopBanner). It is a suggestion,
never a redirect, and it changes nothing about the rule above: the page's
language is still its URL's.
The server only says which locales the page exists in, as
localization.suggestable (LocaleSwitcher::suggestable(): the switcher's
equivalent pages, never a fallback, empty on error pages). That is the same for
every reader, so the HTML stays edge-cacheable. The reader's half is decided in
their browser (resources/js/lib/language-suggestion.ts):
- a language they picked in the switcher or the bar, kept in
localStorageundertablepro:languageas{chosen, dismissed}; - else the first
navigator.languagesentry that maps to a supported locale (resources/data/language-detection.json:zh-TW→zh-Hant,pt-PT→pt-BR, …); - only when neither exists, the country Cloudflare names at
/cdn-cgi/trace, or the time zone's country, for countries with one dominant language.
Steps 1 and 2 run in a head script in app.blade.php before first paint, so
the bar takes the slot without moving anything; step 3, the one network call,
runs after load. ✕ stops that language for good in this browser. No analytics.
The account app runs the same tag and country tables, on the server, for a
reader with no explicit, session or stored language (its LocaleResolver);
the two copies are compared by its LanguageRegionsConfigTest.
resources/data/*.json locale-neutral facts: locales, platforms, engines,
pricing, paid-features, facts, sponsors, comparisons,
assets, redirects, integrations; brand (the logo files,
with the English labels of the English-only /brand)
resources/data/content/{locale}/ page copy: home, download, ios, pricing, faq, blog,
legal, engines, paid-features, one file per page
under features/, databases/, compare/, and
integrations/index.json
resources/data/legal/{locale}/*.md privacy, terms, refund policy; brand guidelines (en only)
resources/blog/*.md posts (English); resources/blog/vi/*.md their translations
lang/{locale}/*.php OG card labels and the static error pages
resources/js/i18n/messages/{locale}/ UI chrome catalogs, typed
A fact is stated once, in data, and copy refers to it: a price comes from
pricing.json, an engine's capabilities from engines.json, a release version
or requirement from platforms.json, a URL from facts.json. Counts are
derived, never typed. A feature newer than the oldest Mac build still served
(platforms.json → mac.floorVersion, which trails while Homebrew catches up)
carries a "0.77"-style label from its sinceAppVersion; bumping the floor
removes every such label at once.
A database, comparison or feature page exists when its slug is in the constant
class in app/Support/Content/Slugs/ (the route constraint) and its content
file exists in a locale. LocaleRoutingTest holds the two lists together.
An integration page is the exception: its route takes any slug-shaped value,
and it exists when resources/data/integrations.json has that slug, so a sync
adds or removes pages without a PHP change or a route cache rebuild.
resources/data/integrations.json and the PNGs in public/images/integrations/
are a byte copy of a TableProApp/integrations
release, brought in by the pull request integrations-sync.yml opens. Nothing
here edits them. Icons and screenshots are PNG only, so no third-party SVG is
served from this origin, and every text field renders as React text.
/integrationsrenders in every locale. English lists every active entry with the registry'ssummary. Another locale lists an entry only whilecontent/{locale}/integrations/index.json→taglineshas it andcontent/en/…→taglinesstill equals the summary, so a changed summary hides the old translation until it is updated./integrations/{slug}renders in English only (IntegrationPages), and archived entries keep their page withnoindex. Other locales answer 404 with a link to the English page.- The hub filters with one function in the server render and the browser
(
components/integrations/model.ts), so the GET form works without JavaScript and the canonical stays/integrations.
Redirects and 410s are not routes. App\Http\Middleware\CanonicalizeRequest
runs first in the global stack, for GET and HEAD only. It drops a trailing
slash and a leading /index.php, writes a language prefix the way
locales.json does (/pt-br/… becomes /pt-BR/…, /en/… loses the prefix),
then looks the clean path up in
resources/data/redirects.json (each entry is a 301 with a to, or a 410)
and in the /databases/{docsSlug} rule. The answer is a single hop that keeps
the query string, so /mariadb-client/?ref=x goes straight to
/mysql-client?ref=x#mariadb; a 410 renders the branded Error page. An unknown
path is a 404, never a redirect to the homepage. A retired URL is never also a
page: the registry drops every path the map answers.
Buying a license, signing in to an account and subscribing to the newsletter are handled by the TablePro backend, which is a separate application and not part of this repository. Requests to those paths never reach this code.
That is why this app runs without a session: it has nothing to keep state
for. StartSession, PreventRequestForgery, ShareErrorsFromSession and
AddQueuedCookiesToResponse are removed from the web middleware group in
bootstrap/app.php, and SESSION_DRIVER is array. It sets no cookies.
Two consequences worth knowing before you write code here:
csrf_token()throws, and there is no CSRF meta tag. Nothing should add one.session(),redirect()->back()->with(...)and Inertia'suseForm().post()do not work. Forms use plainfetchand keep their result in React state — seeuseEmailForminresources/js/hooks/use-email-form.ts, used by the footer and blog newsletter forms.
All anonymous, all rate limited, none needs a token. They are the platform's
paths, so they are always called at the root, never under /vi: nginx routes
them by prefix, and /vi/checkout would never reach the platform. The page's
language travels in the body instead.
| Endpoint | Sends | Returns |
|---|---|---|
POST /checkout |
{tier, cycle, seats?, discount_code?, attribution?, locale} |
{url} — passed to the checkout SDK |
POST /discount/preview |
{code} |
{valid, amount_type?, amount?} |
GET /discount/region |
— | {country, percent}, or {} when no regional discount applies |
POST /newsletter/subscribe |
{email, locale} |
{type, message} |
Every call is a plain fetch with credentials: 'omit'. The platform answers
from its session-backed web group, so a same-origin request with credentials
would send and store tablepro-session and XSRF-TOKEN on public pages;
omit does neither. PublicContractsTest holds every call to it.
locale lets the platform send the buyer's or subscriber's emails in the
language of the page they came from. It is never inferred from anything else,
and it says nothing about currency or region: prices are USD everywhere.
GET /discount/region is the platform's regional (PPP) discount for the
country Cloudflare names for the request, the same answer POST /checkout acts
on when no code is typed. The plan cards ask once per page, after rendering
(useRegionalPricing), and then show what checkout will charge beside the
struck list price, with one line naming the country. The cached HTML, the
structured data and the first paint keep the list prices. The route starts no
session and answers Cache-Control: no-store, private. It is the one place the
site names a regional price (positioning §9).
The plan cards on /pricing and the homepage's #pricing section receive a
checkout page prop from App\Support\Pricing\Checkout:
{provider, couponField}. provider is PAYMENT_PROVIDER (polar or
lemonsqueezy), which must match the platform's own setting, because the cards
open the overlay of whichever provider's URL POST /checkout returns. Polar's
checkout takes a discount code itself, so under Polar the cards show no code
field and never call /discount/preview.
GET /api/newsletter/stats is no longer called. The old footer fetched it for a
subscriber count, and the footer no longer shows one. The endpoint stays on the
platform as a contract for anyone else.
Links to the account are plain <a href="/account?locale={locale}">: a
different application, so never an Inertia <Link>, and never prefixed.
<LocaleLink> and localePath() treat every platform path that way
(PLATFORM_PATHS in resources/js/i18n/paths.ts, the same pattern as
scripts/dev-proxy.mjs), and never append locale themselves, because a
signed /thank-you?order=… URL must arrive exactly as it was signed.
Checkout takes a tier and billing cycle rather than a product identifier, which is why no payment-provider identifier appears anywhere in this repository.
POST /beta/signup was the fifth row here until 2026-09-22. The endpoint still
exists on the backend and nginx still proxies /beta, but nothing on this site
calls it: the iPhone app shipped on the App Store, so the TestFlight invite form
in the closing call to action became a link. Do not add a caller back without
checking the endpoint is still wired.
Neither half of this system can answer "where did this customer come from" on its own. This app sees the arrival and never learns that a sale happened: the overlay that takes the money runs on the payment provider's domain, and the license is written by the backend. The backend sees the sale and never saw the arrival. Google Analytics measures visits on this domain only, so it can report the source of a visit and not the source of a sale.
POST /checkout is the one request in which both are in scope, so the
acquisition source is resolved in the browser and sent in that body as an
optional attribution object:
| Key | Meaning |
|---|---|
source |
utm_source, or a bare ?ref= when there is no utm_source |
medium |
utm_medium |
campaign |
utm_campaign |
term |
utm_term |
content |
utm_content |
referrer |
Origin and path of an off-site referrer, query string dropped |
landing_page |
Path of the first attributable page, without its query string |
first_seen_at |
ISO 8601 timestamp of that first attributable visit |
Only the last two are always present. resources/js/lib/attribution.ts holds
the rules and tests/js/attribution.test.ts holds their proof; the short
version is first touch, ninety days. The first visit carrying a campaign tag
or an off-site referrer wins and is not overwritten, so a reader who arrives
through a comparison page and buys a fortnight later after typing the domain is
credited to the comparison page rather than to "direct". A visit with neither is
not recorded at all, precisely so it cannot take that slot.
The record lives in localStorage under tablepro:attribution, because this
app has no session and sets no cookies. It is disclosed on /privacy.
What the backend does with it today is nothing: the platform ignores the
field and passes none of it to Polar or Lemon Squeezy. The privacy policy says
exactly that. The checkout_started
analytics event is the only part of this that reports anything.
If the backend ever starts keeping it, three things hold, and the privacy policy changes first:
- Tolerate its absence. It is missing for every reader who arrived untagged, and for every browser that refuses storage. It is not a validation error, and checkout must open without it.
- Distrust its contents.
localStoragebelongs to the reader. This app whitelists the keys and clamps every value — tags to 128 characters, URLs to 256 — so the object arrives in a fixed shape, but it is still reader-supplied input and the backend must validate it as such. - Persist it against the license, not just the checkout session. Storing it as provider metadata is what carries it through to the webhook; the attribution is only worth collecting if it survives to sit beside the sale.
Google Analytics 4 replaced self-hosted Plausible on 2026-09-23. Plausible set no
cookies and needed no consent; GA4 sets _ga and _ga_<stream>, which in the
EEA and UK need the reader's permission first. So the tag runs in Consent
Mode, and nothing about it is optional:
app.blade.phpdeclares the tag with every storage type denied, then readstablepro:analytics-consentfromlocalStorageand grantsanalytics_storageif the reader said yes before — all ahead ofgtag('config'), so a returning reader's first page view carries its cookies. Until then GA receives a cookieless ping per page and sets nothing. Google's script itself (about 180 KB) is added only after the load event, once the browser is idle (two seconds after the load in Safari), the way the chat loader is;gtag()queues every call indataLayeruntil it arrives, and it replays them in order. A reader who leaves before then is not counted.ConsentBarasks. It is in the server's HTML, hidden, and the head script addsconsent-opento<html>when no answer is stored. Allow and Decline are the same button at the same weight; that is a legal requirement, not a style choice.resources/js/lib/consent.tsapplies the answer to the running tag and, on a decline, deletes any_ga*cookie already written. "Cookie settings" in the footer and a button in/privacy#cookiesreopen the bar.
The advertising signals (ad_storage, ad_user_data, ad_personalization)
are denied for everyone, always. Nothing here advertises, and /privacy says so.
The account portal is the other half. /account, /checkout, /thank-you
and the newsletter pages are the platform app, on this same origin. It carries a
copy of the tag, the bar and consent.ts, reads the same storage key, and so
shares one answer with this site. Change the key, the consent defaults or the
measurement ID in one repository and the other has to follow in the same
release, or a reader is asked twice and counted as two users.
The platform app also redacts what it sends: its pages are reached through
signed links and order IDs, and GA — unlike Plausible — records the full URL.
See App\Support\AnalyticsLocation there.
Events are sent only once the reader has chosen Allow: trackEvent
(resources/js/lib/analytics.ts) reads the stored answer and drops the event
otherwise. With no answer, or after Decline, Consent Mode would still send it
as a cookieless ping, and the privacy policy promises one signal per page and
nothing else. An event before the answer is dropped, not queued.
Events keep the names the Plausible goals had: download_click (location,
platform), checkout_started (tier, cycle) and
newsletter_signup_clicked (source). The license banner adds
license_banner_view and license_banner_click (version) and
license_banner_dismiss (version, reason: closed or licensed), so a
banner's clicks can be compared with its views, and with checkout_started,
per message. GA4 stores those parameters from the
first hit but only shows them in reports once each is registered as an
event-scoped custom dimension under Admin → Custom definitions. Page changes
between Inertia visits are counted by enhanced measurement's "page changes
based on browser history events", which must stay on in the web stream.
Two third-party scripts load unasked: the Google tag in Consent Mode above, added after the page has loaded, and Cloudflare Web Analytics.
-
Cloudflare Web Analytics is a Cloudflare dashboard setting for tablepro.app. Cloudflare injects its beacon (
static.cloudflareinsights.com) at the edge into every page here and in the account portal; this repository never adds it, so no test here sees it. It sets no cookies, and the privacy policy discloses it (Website, retention and recipients sections). -
Crisp is on every page here and in the account portal. Each layout calls
loadChatWhenIdle()(resources/js/lib/crisp.ts), which adds the loader after the load event, once the browser is idle, so it is never in the server render and never delays the first paint. A "Live chat" button opens the same widget. While the consent bar covers the bottom-right corner (on a phone, reopened from "Cookie settings"), the launcher is hidden. Crisp sets itscrisp-client/cookies as soon as it loads; the privacy policy describes them. -
The Polar or Lemon Squeezy checkout SDK loads at checkout intent, not on every page.
resources/js/lib/checkout-sdk.tsinjects one script tag on the firstpointerenteror focus of a buy button (or on the click itself), and the click awaits it before opening the overlay; if it cannot load, the reader goes to the provider's checkout page instead. The script URLs, version pinned, arecheckoutSdkinresources/data/pricing.json. -
Product Hunt is a plain text link ("TablePro on Product Hunt", URL in
facts.json→links.productHunt): no badge image, no hotlink, no request.
SecurityHeaders is first in the global stack, so it marks every response this
app sends, redirects and error pages included: X-Content-Type-Options,
X-Frame-Options, Referrer-Policy, Permissions-Policy, and
Strict-Transport-Security over HTTPS. Files nginx serves itself (/build,
/images, /og, the sitemap) never reach it.
HTML also carries a Content-Security-Policy, built by
App\Support\Security\ContentSecurityPolicy:
- Inline scripts are admitted by hash, computed from the response. There is no nonce, so the page stays cacheable at the edge. An inline script added in the browser is refused.
- Third-party sources are listed per vendor in that class, and only for a vendor that is configured. A new third party, or a new host for one already there, is an edit to that class. Without it the browser blocks the request.
- Every page carries the same sources, because an Inertia visit keeps the policy of the document the reader loaded first.
- No policy is sent while the Vite dev server runs, or with Laravel's debug page.
- A Cloudflare feature that injects an inline script (JavaScript Detections, Rocket Loader) would be blocked. None was on as of 2026-10-08. After turning one on, open a page and read the browser console.
php artisan serve runs only this app, so the platform's paths return 404 on
your machine. To exercise a journey that crosses between the two apps, run both
behind one origin, the way nginx does in production:
php artisan serve --port=8000 # this repository
(cd ../license && php artisan serve --port=8001) # the platform
npm run dev:proxy # http://localhost:8080scripts/dev-proxy.mjs sends /account, /checkout, /webhooks,
/newsletter, /beta, /discount, /thank-you, /api/newsletter and
/platform-build to the platform and everything else here, with
X-Forwarded-* headers. Set WEB_DOMAIN=localhost in both .env files (the
platform's routes are domain-scoped and domain matching ignores the port) and
use built assets in both. Keep payments mocked and mail on the log driver: no
real checkout, message or customer data.
For styling or copy work that does not touch a form, stub the fetch instead, or ignore it.
Every content image (screenshots, crops, phone captures, diagrams, blog
figures) is a slot in one manifest, resources/data/assets.json. Its kinds
fix the geometry and export rules; each entry carries the owner-handoff fields
and a status, the only render switch. <AssetSlot id>
(resources/js/components/ui/asset-slot.tsx) renders a placeholder as a
labelled, described box of the final aspect ratio, with no <img> and no
request, and a supplied entry as <picture> sources with light and dark
variants. Markdown places a slot with <asset-slot id="…"></asset-slot>. A
supplied priority asset gets a head preload through the lcpAsset page prop.
The placeholder is for the owner, so it shows only outside production.
HandleInertiaRequests shares assetPlaceholders (false when APP_ENV is
production), and there a slot with no image renders nothing. A component that
wraps a slot or gives it a column narrows the id with useShownSlot() first, so
the block becomes text only instead of keeping an empty box.
The browser loads resources/js/lib/data/asset-slots.json for shared geometry
and one asset-locales/{locale}.json catalog for the active language's text.
The page resolver waits for that catalog before client or SSR rendering;
the cache is keyed by locale so concurrent SSR requests cannot share the wrong
language. English editorial figures retain their English text.
php artisan assets:handoff writes these files and the owner's brief,
docs/visual-assets.md, from the manifest and the per-family fragments in
docs/rebuild/assets/. Edit the manifest or a fragment and rerun the command.
assets:handoff --check runs in CI and fails on any stale generated file.
npm run build produces both the client and SSR bundles. scripts/deploy.sh
installs, builds, regenerates the sitemap and caches config and routes. Because
resources/data/locales.json decides which route groups exist, a change to it
rebuilds the route cache like any PHP change (docs/deployment.md).
The sitemap is generated on the host because it carries a lastmod date. Open
Graph cards are the opposite — they are committed under public/og/, so
nothing in production needs a browser engine. php artisan og:generate --type={site|blog|database|compare|feature|all} --locale={supported-locale|all} renders
them from each page's content (og block, or the post's front matter):
English at public/og/{type}/{slug}.png and the generic public/og.png,
Other languages under public/og/{locale}/. A page with no card of its own, or whose own
card file is missing, falls back to its language's generic card: the bespoke
og-site card (public/og/bespoke/og-site-{locale}.png, an owner asset in
resources/data/assets.json) once that entry is supplied and the locale's file
exists, else the generated one. Only when no generic card exists does the page
emit no og:image, rather than a broken one. Seo/OgCardsTest fails in either case. Regenerate them through
the og cards workflow rather than on a schedule; a scheduled run would
rewrite tracked files and leave the deploy checkout dirty.
Release facts (versions, dates, requirements and the App Store price) are data
in platforms.json, not fetched per page. php artisan release:check compares
them with GitHub, the Sparkle appcast, Homebrew and the App Store, and exits
non-zero on any drift or unreadable source. It needs the network, so it is run by hand before a launch
or after a release, never in the test suite.
Facts about other products are data in comparisons.json, each with its source
and the date it was read. php artisan comparisons:check lists the products
checked more than 30 days ago (--max-age) and compares every last release
that cites a GitHub releases page with GitHub. Prices and features have no feed,
so a stale row means re-reading that product's sources. Also run by hand.
A few files are byte-identical with the platform app: the design tokens, the
fonts, the theme partial and the consent and theme modules.
docs/shared-files.md lists them and how to change them.