Search by

tinvar / skeleton

tinvar

A new Tinvar application: composer create-project tinvar/skeleton my-app

Package info

github.com/tinvar/skeleton

Type:project

pkg:composer/tinvar/skeleton

Statistics

Installs: 22

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.4.10 2026-11-08 01:10 UTC

This package is auto-updated.

Last update: 2026-11-08 01:22:57 UTC


README

A PHP 8.5 core with a React Server Components renderer: PHP owns every request -- routing, middleware, authorization, validation, the ORM, queries and actions -- and a small pool of Node processes turns a React tree into HTML and an RSC stream.

Start

You need PHP 8.5 with pdo_sqlite, sockets, pcntl and posix; Composer 2; and Node 22.13 or later. FrankenPHP comes with setup: where none is at tools/frankenphp, in TINVAR_FRANKENPHP or on your PATH, it downloads FrankenPHP's official binary for your machine (macOS on Apple silicon, Linux on x86-64 or ARM64) to tools/frankenphp, held to the sha256 the framework pins. Elsewhere, install FrankenPHP (https://frankenphp.dev) and put it in one of those places.

composer create-project tinvar/skeleton my-app
cd my-app
bin/dev

composer create-project refuses a directory that is not empty. Once the app is installed it runs bin/tinvar setup, once: it generates the app key and the probe token into your state directory (~/.local/state/tinvar/, never the project), writes the TypeScript the views use from the PHP, makes the SQLite database, runs npm ci --ignore-scripts, and downloads FrankenPHP where none is found (bin/tinvar frankenphp:install does that alone). It runs nothing else it fetched.

bin/dev is dev mode: Vite's dev server renders the app from source, an edit shows in the open page, codegen runs again as the PHP changes, and the dev toolbar sits over each page. Loopback only, never in production. Started with TINVAR_ENV=local, it also draws the pages and components in resources/workbench.tsx on their own, from the samples there, at /_vite/__workbench and /_vite/__playground. bin/tinvar render --dump PATH prints a page's HTML and RSC payload, and bin/tinvar mcp is an MCP server for an assistant; both read a build made with bin/tinvar build --profile=test --out=dist-test, on a copy of the database -- on Postgres or MySQL, a copy made on its server, on MySQL as the user TINVAR_DUMP_MYSQL_DSN names. To serve the build as production does:

npm run build
bin/serve

What is here

app/Kernel.php            the composition root, and the only file at app/'s root
app/Http/Pages/           #[Page] and #[Layout] classes; Home is the home page
app/Http/Queries/         #[Query] classes, which server components call
app/Http/Actions/         #[Action] methods, which forms post to
app/Http/Requests/        form requests: what an action is posted
app/Http/Data/            the Data classes that cross to the renderer
app/Database/Models/      #[Table] models
app/Database/Migrations.php  where the migrations are
app/Policies/             what #[Authorize] asks
app/Auth/                 the users the accounts kit reads, and the flashes
app/Config/               the app's #[Config] settings
database/migrations/      one migration per file
resources/pages/          the views, one .tsx per page or layout
resources/components/     server and 'use client' components
resources/entries/        the renderer's rsc, ssr and browser entries
resources/types/          what bin/tinvar codegen writes
tests/Feature/            Pest: pages, queries and actions through PHP's request path
tests/components/         Vitest: client components against codegen's mocks
public/worker.php         the FrankenPHP worker
Caddyfile                 how FrankenPHP serves the app

The home page lists notes with a query (RecentNotes) and adds one with an action (NoteActions::add, posted by <NoteForm>). Both say #[PublicAccess] and NotePolicy lets anyone in, signed in or not: decide there who may.

Accounts

The app has accounts from the start, from the framework's accounts kit (Tinvar\Accounts, ADR 0153): signing up by a link to the address, signing in by password or passkey, two-factor codes from an authenticator app with recovery codes, a forgotten and a changed password, a change of address by a link to the new one, and signing out everywhere. The pages are under app/Http/Pages/Account/ and resources/pages/account/, the actions under app/Http/Actions/Account/, the emails' words in app/Mail/Account/ and resources/emails/, and the users in app/Database/Models/Account/User.php through app/Auth/Account/Users.php -- change those two to keep your people in a table of your own. A page for signed-in users says #[Middleware(RequireSignIn::class)].

Its email goes through the queue, which bin/dev and bin/serve run beside the server. In development it lands in database/app.sqlite-mail.log, its links on http://localhost:PORT; open the app on localhost rather than 127.0.0.1, which passkeys refuse. In production set APP_URL (the https origin every link is built on and passkeys are made for), APP_MAIL_FROM and TINVAR_MAIL_PROVIDER: the app refuses to start without them.

API tokens

/account/api-tokens lets a signed-in user make personal access tokens for a script of theirs (ADR 0159): named, scoped, optionally expiring, listed and revoked, the text shown once. A script calls an operation marked #[RequiresScope] at POST /_api/{name} with Authorization: Bearer tvr_...; the app declares one, app/Http/Queries/RecentNotes.php with notes:read. A token outlives a sign-in elsewhere, and ends when its user signs out everywhere, sets a new password or address, or turns two-factor off.

The admin panel

/admin/notes lists, searches, changes and deletes the notes: one class, app/Http/Resources/NoteResource.php, an #[AdminResource] in the admin panel (ADR 0152), drawn by the panel's three views in resources/pages/admin/resources/. Kernel::panels() defines the panel, behind App\Auth\Admin\RequireAdmin: a visitor is sent to sign in, and only the accounts APP_ADMINS names, comma-separated, are let in. With it unset, bin/dev lets every signed-in account in and everywhere else nobody may. bin/tinvar make:resource Orders/Order --fields=... --panel=admin adds a model's screens beside its own pages.

bin/tinvar make:page, make:query, make:action, make:model and make:migration write the next ones into this layout, each registered by being there, and a page's view in resources/entries/rsc.tsx. Give a name a domain once the app has more than one: make:page Orders/OrderDetail writes app/Http/Pages/Orders/OrderDetail.php and its Data beside the domain's in app/Http/Data/Orders/. bin/tinvar help lists every command.

Check it

vendor/bin/pest
npx vitest run
npm run lint
bin/tinvar codegen --check
bin/tinvar check
bin/tinvar doctor

bin/tinvar doctor checks everything the app needs to boot and serve, with this machine's environment, and reports every problem at once, each with its fix; --fix does the safe ones.

bin/tinvar check holds every page, query and action to the security model's build rules -- every model input is authorized, or says why not -- and the lockfiles to Kernel::packages().

Settings and secrets

The app reads its settings from the environment, through a typed #[Config] class, app/Config/AppConfig.php: each constructor parameter is one variable, parsed to its type at boot, and a key missing or malformed stops the server with the variable named, never its value. A secret is typed Tinvar\Config\Secret, which no dump, log or render shows. bin/tinvar config lists every key and where its value came from, with secrets hidden; .env.example lists them with nothing secret filled in. On a developer's machine bin/tinvar reads .env into its environment first, never over a variable you set and never a variable named like a secret: name a secret's file in its _FILE variable instead. The app key is TINVAR_APP_KEY, or the file bin/tinvar setup generated. /_health, /_ready and /_metrics let in the probe token alone, as Authorization: Bearer <token>: it is TINVAR_PROBE_TOKEN, or the .probe-token file setup generated beside the key. No caller gets in for being on loopback -- a TLS terminator on the same machine is on loopback too -- unless TINVAR_PROBE_TRUST_LOOPBACK=1.

curl -H "Authorization: Bearer $(cat ~/.local/state/tinvar/my-app-*.probe-token)" http://127.0.0.1:6123/_health

Never commit a key: .gitignore and .dockerignore keep .env, *.key, secrets/ and the database out.

Production

Dockerfile and compose.yaml build and run one container: FrankenPHP and the renderers as two users, nothing root's once both are started, a read-only root, every base pinned by digest and every binary by its sha256. The image holds no secret; it refuses to start without an app key and a probe token.

mkdir -m 0700 secrets
openssl rand -base64 32 >secrets/app_key
openssl rand -hex 24 >secrets/probe_token
openssl rand -hex 32 >secrets/trusted_proxy_secret
chmod 0600 secrets/*
cp .env.example .env    # fill in APP_URL, APP_MAIL_FROM and APP_MAIL_PROVIDER
docker compose up -d --build

The secrets are compose secrets, files in secrets/, never in .env: docker inspect shows none of them. Compose mounts each at /run/secrets/<name>; the entrypoint reads the file each _FILE variable names there, as root or as the file's owner, before it starts anything, and hands PHP the value. It refuses a file the renderers' user could read. secrets/ is in .gitignore and .dockerignore. A mail provider's key is one more file there, named in its _FILE variable: compose.yaml's header shows how.

Put a TLS terminator in front: the cookies are __Host-, which browsers send over https alone. bin/tinvar deploy:init fly (or render, railway, vps-systemd, vps-compose) writes a platform's deployment, with its edge pinned as the trusted proxies, and generates its secrets outside the project.

Licence

The skeleton is Tinvar's, under the MIT licence: that is what LICENSE and "license": "MIT" in composer.json say. Your app is yours. Replace both with your own licence -- or delete LICENSE and set "license": "proprietary" -- before you publish or ship it. The framework's packages in vendor/ and node_modules/ keep their own MIT licences either way.