Site themes
Site themes
A theme is “just a folder” in resources/scaffold/themes/ — adding it to the catalog does
not require registering its name anywhere. Checked against
Niang\Core\Console\ProjectScaffolder and ThemePackageInstaller.
Catalog
Eight themes ship with the framework, plus minimal (the demo skeleton, always last and the default). Their labels are in French, as the CLI displays them:
| Slug | Label | Meaning |
|---|---|---|
vitrine | Site vitrine / informationnel | Showcase / informational site |
ecommerce | Boutique en ligne | Online store |
blog | Blog / magazine | Blog / magazine |
portfolio | Portfolio | Portfolio |
landing | Landing page one-page | One-page landing page |
auth | Application avec comptes (inscription, connexion, espace membre) | Application with user accounts — see “auth” starter |
api | API REST (JSON, jetons, CORS, OpenAPI) | JSON REST API — see “api” starter |
saas | SaaS (organisations, membres, abonnements) | SaaS (organizations, members, subscriptions) — see “saas” starter |
minimal | Minimal — squelette de démonstration (défaut) | Minimal — demo skeleton (default) |
See Getting started for the question asked during installation, --type= and NIANG_SITE_TYPE.
Structure of a theme
A theme mirrors a project's directory tree — only the files actually provided are copied:
resources/scaffold/themes/vitrine/
├── app/Controllers/
├── config/site.php
├── public/css/theme.css # builds on niang.css variables, never reinvents them
├── resources/views/
│ ├── layouts/
│ └── pages/
├── routes/web.php # fully replaces the target project's one
├── tests/Feature/ # the theme's tests, shipped with it
└── theme.json # optional
theme.json (every field optional):
| Key | Purpose |
|---|---|
label | Label shown in the catalog (default: the capitalized slug). |
order | Position in the catalog, ascending (default: 100). |
modules | Modules from resources/scaffold/modules/ to install with this theme — see below. |
remove | Paths of the target project to delete before copying (e.g. the demo that a real theme replaces). |
setup | niang commands run automatically when the project is created — see below. |
next_steps | Commands suggested after installation; those already run successfully by setup are removed from the list. |
notes | Information shown after those commands (e.g. the credentials of a test admin account). |
How shared/ and the theme are merged
resources/scaffold/shared/ follows the same tree and provides what every
theme has in common (the niang.css/niang.js design system, components, base
layout, error pages) — never duplicated in each theme. ProjectScaffolder::install()
does, in order:
- Reads the
removeofshared/theme.json, then that of each declared module, then that of the theme, and deletes those paths from the target project (the demos this theme replaces — views, tests specific to the minimal skeleton). - Copies
shared/over the target project. - Copies each module declared in
modules(in the order listed), overshared/. - Copies the theme's folder over everything, last — its files win on collision, including over a module's.
app/Middleware/, app/Models/ and database/migrations/ are part
of neither shared/ nor a theme: they remain those of
the base skeleton copied by niang new, available to every theme that needs them
(authentication, CSRF...).
Installing a theme replaces, it does not merge: that is intentional, it only happens once, when the project is created, before anyone has touched anything — merging would be more fragile for no real benefit.
Modules: building blocks shared between themes
A module (resources/scaffold/modules/<name>/, same tree as a theme) is a building
block reused by several themes without being a site type itself — the admin area, for example,
shared by the store and the blog. It is declared in theme.json and copied after
shared/ but before the theme (which can therefore replace any of its files):
{
"label": "Boutique en ligne",
"order": 20,
"modules": ["admin"],
"next_steps": ["./bin/niang migrate", "./bin/niang db:seed", "./bin/niang serve"],
"notes": [
"Compte administrateur de test (créé par db:seed) : admin@example.com / admin1234",
"Connectez-vous sur /login : vous arrivez sur le tableau de bord /admin."
]
}
The admin module (shipped with the framework) brings the controllers, views and
migrations of an admin dashboard; the ecommerce and blog themes both
declare it rather than duplicating that code. A declared module that does not exist under
resources/scaffold/modules/ makes the installation fail with an explicit error rather
than a half-built project.
“auth” starter: an application with accounts
The starting point for a product where users have an account. No demo content to remove: a home page, then a member's whole journey.
composer create-project niangpro/niangpro my-app
# choice: auth (or NIANG_SITE_TYPE=auth)
cd my-app && ./bin/niang serve
| Page | Purpose |
|---|---|
/register, /login, /logout | sign-up, login (“remember me”, throttled attempts), logout |
/forgot-password, /verify-email/{id} | signed, time-limited links sent by email |
/auth/google/redirect, /auth/github/redirect | OAuth login, active as soon as the provider is configured in .env |
/tableau-de-bord | the member area, where your product starts (resources/views/pages/dashboard.php) |
/compte | profile (a new address becomes “unverified” again), password, two-factor authentication, deleting the account and its API tokens |
After sign-up or login, a member lands on User::homePath(), i.e. /tableau-de-bord.
The account pages come from the account module, which other themes can declare to get the same
authentication screens. The shipped tests (tests/Feature/AccountTest.php,
AuthStarterTest.php) cover the whole journey, CSRF included.
“api” starter: a JSON REST API
For a mobile app or SPA backend: no HTML page, everything answers in JSON.
| Route | Purpose |
|---|---|
POST /api/v1/register | creates an account, returns its first token |
POST /api/v1/tokens | email, password, device_name (and code when two-factor is on): a token |
DELETE /api/v1/tokens/current | revokes the token used (logs the device out) |
GET /api/v1/me | the token's account |
/api/v1/notes, /api/v1/notes/{id} | sample resource: paginated CRUD; another account's note answers 404 |
curl -X POST http://127.0.0.1:8000/api/v1/register -H 'Content-Type: application/json' \
-d '{"name":"Awa","email":"awa@example.com","password":"secret123","device_name":"curl"}'
# {"token":"ed9c...","user":{...}}
curl http://127.0.0.1:8000/api/v1/notes -H 'Authorization: Bearer ed9c...'
Validation through FormRequests (app/Requests/), responses through JsonResources
(app/Resources/), CORS with an OPTIONS route per path, rate limiting on sign-up and
login. The OpenAPI description is generated into public/openapi.json when the project is created;
run ./bin/niang openapi again after every route change.
“saas” starter: organizations, members, subscriptions
The base of online software sold to teams. Each organization is a tenant: its
pages live under /o/<slug> and its data (projects, members, invitations) is never visible to
others — another organization answers 404. The account pages come from the account module, as in
the “auth” starter.
| Page | Who | Purpose |
|---|---|---|
/organisations | any logged-in member | their organizations, creating a new one (they become its owner) |
/o/<slug>, /o/<slug>/projets | members | dashboard, sample resource; deletion for admins only |
/o/<slug>/membres | members (management: admins) | roles, email invitations, removal, leaving the organization |
/o/<slug>/abonnement | admins (changes: owners) | plans, subscribing, cancelling |
/invitations/{token} | the invited person, logged in with the invited address | joining the organization; single-use link, valid 7 days |
Three roles: owner, admin, member. A route requires a minimum
role with EnsureTeamMember::class . ':admin'. There is always at least one owner: the last one cannot
be demoted, removed, leave, or delete their account while the organization has other members.
Plans and payment
'provider' => env('BILLING_PROVIDER', \App\Billing\FakeBillingProvider::class),
'plans' => [
'free' => ['name' => 'Gratuit', 'price' => 0, 'limits' => ['projects' => 3, 'members' => 2]],
'pro' => ['name' => 'Pro', 'price' => 29, 'limits' => ['projects' => null, 'members' => 25]],
],
Limits are enforced (creating a project, inviting and accepting an invitation) with
Billing::allows('projects'). Payment goes through the App\Billing\BillingProvider
interface: checkout() (URL of the provider's payment page), cancel(), and
webhook() for confirmations received on POST /billing/webhook, which apply the change with
Billing::apply(). FakeBillingProvider activates a plan without payment; it is refused
with APP_ENV=production. For Stripe, Paddle, PayDunya, CinetPay..., write your class and set it in
BILLING_PROVIDER.
Creating a new theme
Adding a theme = adding a folder under resources/scaffold/themes/<slug>/ — it shows up in the catalog on its own, nothing to register anywhere else:
mkdir -p resources/scaffold/themes/magazine/{app/Controllers,resources/views,routes}
# ... build the theme ...
./bin/niang new test-magazine --type=magazine # check that it installs correctly
The slug must match ^[a-z0-9][a-z0-9_-]*$ and differ from minimal (reserved, always the skeleton as-is).
Publishing a theme as a Composer package
To distribute a theme separately from the framework (the “theme extensibility through separate
Composer packages” milestone): an ordinary Composer package, with an
extra.niangpro-theme key pointing to the theme's folder (already named after the
desired slug — its basename becomes the slug):
{
"name": "acme/niangpro-theme-magazine",
"extra": {
"niangpro-theme": "resources/magazine"
}
}
./bin/niang theme:add acme/niangpro-theme-magazine
The command runs composer require --dev, then ThemePackageInstaller copies
vendor/acme/niangpro-theme-magazine/resources/magazine/ to
resources/scaffold/themes/magazine/ — after that, the theme is an ordinary local folder,
visible in niang new --type=magazine exactly like the five themes shipped originally. No
change to ProjectScaffolder is required: “a theme is just a folder” stays true
wherever it comes from.