Project layout
composer create-project 4apps/staticphp ./ followed by the scaffold step it runs
automatically leaves you with two things layered on top of each other: the skeleton’s own
tracked tooling - lib/, presets/, scripts/, the docker and CI setup - and one generated
application under src/. Overview covers why the
application is generated rather than tracked from the start; this page is the map of what
you actually get, from the project root down to the file a first request runs.
The tree below is what a project made from the skeleton looks like. It is not identical to
a checkout of the skeleton repository itself: --new-project deletes the handful of files
that only ever described the skeleton - the release scripts, CHANGELOG.md, UPGRADE.md -
before handing control back, so none of those appear here.
Top level
Section titled “Top level”| Entry | Tracked? | What it is |
|---|---|---|
composer.json |
tracked | Requires 4apps/staticphp-core, autoloads lib/ as StaticPHP\Skeleton\ and tests/ as StaticPHP\Skeleton\Tests\, and defines the setup, test, lint and stan composer scripts. |
composer.lock |
tracked | Locked dependency versions. |
lib/ |
tracked | The skeleton’s own php tooling - the scaffolder, the cli commands, the upgrader. Covered further down this page. |
src/ |
tracked | Your application(s). One directory per application, laid down from presets/ by the scaffolder at creation time. Its anatomy is the rest of this page. |
presets/ |
tracked | The source src/* is built from: _base plus a flavour (twig, react) copied on top. See applications and presets. |
scripts/ |
tracked | Bash and php utilities: build_info.bash, clear_cache.bash, code_tests.bash, the git hooks, post_create_project.php, i18n_integration.php, migrations_integration.php, phpstan_bootstrap.php, watch_files_dev.bash. version.bash, release_tag.bash and upgrade_v2_namespaces.bash only ever described the skeleton itself and are gone by the time you see this tree. |
staticphp |
tracked | The cli entry point - an executable php file with no extension. See the cli. |
tests/ |
tracked | Tests for lib/ - the scaffolder, the merge engine, ownership rules - not for your application. Your application’s tests live under src/<App>/Tests/. |
docker/app/ |
tracked | Dockerfile, a supervisord config and the container’s own scripts (build.bash, run.bash, serve.bash, …). See the dev environment. |
docker-compose.yml |
tracked | develop, testing and build services built from the staged Dockerfile. |
.dockerignore |
tracked | What docker build never sends as context. |
.devcontainer/ |
tracked | devcontainer.json, pointing VS Code’s Dev Containers extension at the develop compose service. |
.github/workflows/ci.yml |
tracked | The CI pipeline copied in from the skeleton - five jobs, each starting with a scaffold step that is a no-op once an application is already tracked. See the five CI jobs. |
.vscode/launch.json |
tracked | Editor debug configuration. |
resources/ |
tracked | logo_50.png and logo_1024.png - artwork from the skeleton’s own README.md. The generated README.md no longer embeds them, but the files are not stripped either. |
README.md |
tracked | Rewritten by post_create_project.php at creation time - see creating a project. An upgrade.json “once” file afterwards - staticphp upgrade reports it but never writes it, present or absent. See upgrading a project. |
LICENSE |
tracked | Whatever license the skeleton shipped with. |
.gitignore |
tracked | Standard ignores, plus the per-application patterns for Cache/*/, Public/assets/dist/, uploads and the like. |
.editorconfig, .prettierrc, eslint.config.mjs |
tracked | Editor and JS/TS style config. See the asset pipeline. |
phpcs.xml, phpstan.neon, phpunit.xml |
tracked | php style, static analysis and the tooling test suite. The root phpunit.xml runs tests/ against lib/ only - each application carries its own, covered further down this page. See testing and CI. |
tsconfig.base.json |
tracked | Shared TypeScript config every application’s own tsconfig.json extends. |
rspack.config.js, package.json, package-lock.json |
tracked | The asset bundler and its npm dependencies, shared across every application under src/. |
upgrade.json |
tracked | The ownership rules staticphp upgrade reads: what it never touches, what it writes once, what a fresh project strips. See upgrading a project. |
.version |
tracked | major.minor only - reset to 0.1 at creation. scripts/build_info.bash appends the commit count as the patch number. Also an upgrade.json “once” file. |
.env.example |
tracked | Template for the root .env - container user ids, git identity for the development image. |
.env |
generated, gitignored | Created from .env.example by composer setup, with LOCAL_USER_ID/LOCAL_GROUP_ID filled in from the running user so bind-mounted files do not end up root-owned. |
.staticphp/manifest.json |
generated at creation, tracked afterwards | Written by the scaffolder: the project’s origin repository, and per application which preset and release it is on. Read and updated by every staticphp upgrade. |
.build_info.json |
generated, gitignored | Written by scripts/build_info.bash: version, commit hash, commit date, asset version. Read by every application’s Config/App.php. |
vendor/, node_modules/ |
generated, gitignored | Composer and npm dependencies. |
.phpcs.cache, .phpunit.cache/ |
generated, gitignored | Tool caches for phpcs and phpunit. |
lib/ vs src/
Section titled “lib/ vs src/”This is the pairing that trips people up, because both look like “the code”, and they are not the same kind of thing at all.
lib/ is the skeleton’s own tooling. It is PSR-4 autoloaded under StaticPHP\Skeleton\
composer.jsonmaps the namespace straight to the directory, andtests/the same way underStaticPHP\Skeleton\Tests\inautoload-dev. Five files:
lib/├── Cli.php StaticPHP\Skeleton\Cli::commands() - the skeleton's cli registry├── Manifest.php .staticphp/manifest.json - load, save, pin, unpin├── App/│ ├── Cli.php `staticphp app ...` - add, list, dispatches upgrade│ ├── Presets.php Which preset to use: SP_PRESET, a prompt, or the default│ └── Scaffolder.php Copies _base then a preset into src/<Name>└── Upgrade/ ├── Cli.php `staticphp upgrade` / `staticphp app upgrade` - the human-facing part ├── Upgrader.php Three-way comparison of a current, base and target tree ├── OriginRepo.php Read-only access to the skeleton's own git repository └── Ownership.php The ignore/once/strip lists from upgrade.jsonNone of it has anything to do with your application. It has nothing to do with routing,
controllers or requests - lib/ never runs during a web request at all, only from the
staticphp cli. The cli and
upgrading a project cover what each of these
classes does; this page only needed you to know where they live and that they are a separate
namespace from anything under src/.
src/ is where applications live, and it is deliberately not PSR-4. There is no
autoload entry for it in composer.json at all. A module’s namespace root is its own name
Defaults\Controllers\Welcome- resolved at runtime against whichever application’sModules/directory the current request’s front controller pointed at, which is how one repository can serve several applications side by side without their namespaces colliding or their classes needing to be told apart by a shared prefix. That resolution is core’s, not the skeleton’s, and it is exactly what makes multiple applications possible in the first place - see running multiple applications for the mechanism rather than have it re-explained here.
The anatomy of src/<App>/
Section titled “The anatomy of src/<App>/”Everything below is one application - Application is the name the default preset choice
gives it, but nothing pins the name to that; staticphp app add <Name> takes any name
matching /^[A-Z][A-Za-z0-9]*$/.
src/Application/├── Cache/ Empty, gitkept. Holds compiled twig templates under Views/│ when twig is enabled and debug is off; i18n's internal│ catalog cache and the file cache backend land here too,│ each only once its own config is loaded. Ignored by git│ below the top level.├── Config/│ ├── App.php Version and build info - see below│ ├── Config.php The main $config array - see below│ └── Routing.php $config['routing'] - see below├── Files/ Empty, gitkept. Not read by any framework code - a place for│ the application's own file storage to exist from the start│ rather than being created on first upload.├── Helpers/│ └── Bootstrap.php Autoloaded via $config['autoload_helpers'] - headers,│ sessions, twig registration; whatever runs once per request│ before routing├── Migrations/ Empty, gitkept - see below├── Modules/│ └── Defaults/ One example module - see below├── Public/│ ├── assets/src/ Source js/ts/scss - see below│ ├── dev-router.php│ ├── favicon.ico│ ├── .htaccess│ └── index.php The front controller - see below├── Tests/│ ├── autoload.php Bootstrap for this application's own test suite│ ├── status_probe.php Runs the front controller in a subprocess to assert real│ │ HTTP status codes│ └── Modules/Defaults/Controllers/│ ├── DebugTest.php│ └── WelcomeTest.php├── Views/│ └── components/ Menu partials shared across the whole application, outside│ any one module - menu_type_100.html and friends├── phpunit.xml This application's own test suite - bootstrap="Tests/autoload.php"├── tsconfig.json Extends ../../tsconfig.base.json, scoped to this app's assets└── .env Generated by the scaffolder from .env.example - APP_ENV, LOGGING_* - gitignoredEach application carries its own phpunit.xml, tsconfig.json and .env because
PUBLIC_PATH is a constant, not derived: one php process is exactly one application, so
nothing about config, tests or the asset build can be shared at that level even when several
applications sit in the same repository. See
why a constant rather than a method
for the reasoning, and the front controller
for how PUBLIC_PATH derives APP_PATH, APP_MODULES_PATH and the rest. Migrations/ sits empty
until you load the migrations config - its shipped default is APP_PATH . '/Migrations' -
see migrations.
The three config files
Section titled “The three config files”Config::load(['Config', 'Routing']) is what the bootstrap calls, resolving to
Config/Config.php and Config/Routing.php under APP_PATH; App.php is pulled in
separately, by name, through $config['autoload_configs'] = ['App'] in Config.php itself.
Configuration is the reference for how
Config::load() resolves file names in general and what the framework reads back out of
$config; this section only covers what the three shipped files actually contain.
Config/App.php
Section titled “Config/App.php”Version and build metadata, and nothing else. It reads the gitignored .build_info.json
first and falls back to .version with a .dev suffix when that file is absent, which is
the state of a plain checkout with no build step run. What writes .build_info.json, and
when, is the asset pipeline’s subject; the
fallback branch here is the whole of what this page needs to say about it:
<?php
$versionFile = __DIR__ . '/../../../.version';$majorMinor = ( is_file($versionFile) ? trim((string) file_get_contents($versionFile)) : '0.0');
$config['version'] = "v{$majorMinor}.dev";$config['git_commit_hash'] = '';$config['git_commit_date'] = '';
// No build to pin assets to, so bust the cache on every request$config['asset_version'] = time();asset_version is what a <link> or <script> tag’s cache-busting query string uses -
layout.html in the default module reads it as config.asset_version. It comes from the
build info when there is one, falls back to the commit hash, and is forced to time() in an
explicit dev environment even when build info exists, so an asset edited between requests is
never served stale from a developer’s browser.
Config/Config.php
Section titled “Config/Config.php”The main $config array. Before assigning anything it loads .env through
Symfony\Component\Dotenv\Dotenv twice - BASE_PATH . '/.env' first, then
APP_PATH . '/.env' as an overload. BASE_PATH is src/, not the project root, so the
first call is a no-op in a fresh project; the second is what actually reads the
scaffolder-created src/Application/.env. It then assigns every key the bootstrap and
router read. The keys that actually appear in the shipped file:
base_url, disable_twig, environment, debug, debug_check, logging (display_level,
log_level, report_level, report_email, report_webhook, report_email_func,
report_webhook_func), error_pages, request_uri, query_string, script_name,
client_ip, allowed_hosts, trust_proxy_headers, trusted_proxy_hops, view_env_keys,
url_prefixes, module_paths, autoload_configs, autoload_helpers, before_controller.
Configuration and
the config api cover what each of these does and how Config
itself works; what is worth knowing here is what the shipped defaults actually are.
environment reads SP_ENVIRONMENT if a front controller defined it, otherwise
$_ENV['APP_ENV']; debug follows from it - anything other than dev is not debug.
autoload_configs is ['App'] and autoload_helpers is ['Bootstrap'], which is the whole
reason App.php and Helpers/Bootstrap.php run without anything calling them by name.
Config/Routing.php
Section titled “Config/Routing.php”$config['routing'], nothing more. The shipped file has one entry, the default route:
<?php
$config['routing'] = [
// Default Controller and Method names '' => 'Defaults/Welcome/index',
// Rest of the routing # '^([0-9]+)$' => 'orders/details/$1' # Example rewrite: http://example.com/1234 -> http://example.com/orders/details/1234];How the array is read - which key wins when several match, how a rewrite interacts with url
prefixes, what urlToFile() does with the default entry - is
the router’s job to explain, not this
page’s.
A module’s shape
Section titled “A module’s shape”Modules/Defaults/ is the one module every generated application starts with - Defaults
because that is what an unqualified / resolves to via the default route above.
Modules/Defaults/├── _bootstrap.php Runs once per request that reaches this module - sets│ view_data.js_include here├── Controllers/│ ├── Welcome.php Serves / - Defaults/Welcome/index│ ├── Debug.php /defaults/debug - environment and db report, debug mode only│ └── Test/Test.php A controller nested one directory deeper, for reference├── Data/│ └── MainMenu.php Extends StaticPHP\Presentation\Models\Menu\Menu├── Helpers/│ └── Bootstrap.php Empty example - a module-level equivalent of the│ application-level Helpers/Bootstrap.php, included when│ the router walks into this module└── Views/ ├── layout.html ├── index.html └── example.html_bootstrap.php and Helpers/Bootstrap.php are both include-if-present, not registered
anywhere - Router::loadController() walks from the matched controller’s directory up to
APP_MODULES_PATH, including a _bootstrap.php at every level that has one, and separately
includes Helpers/Bootstrap.php for the resolved module before that walk starts. Adding
either file to a new module is enough; there is no list to update. See
controllers for what a controller class itself is
expected to look like and what construct()/destruct() do around it.
Public/
Section titled “Public/”index.php is the front controller - the one file that has to exist before anything
else does, because it is what defines PUBLIC_PATH and requires the framework bootstrap:
<?php
require dirname(__DIR__, 3) . '/vendor/autoload.php';
define('PUBLIC_PATH', __DIR__);
require StaticPHP\Core\Bootstrap::FILE;The front controller is the full
reference for what defining PUBLIC_PATH here derives and in what order.
dev-router.php exists for php -S - the built-in development server, which has no
rewrite rules of its own. It refuses to run under anything else (SERVER_SOFTWARE has to
contain Development Server), resolves the request path against the filesystem, serves an
existing file as-is or through the php interpreter, serves an existing directory’s
index.php/index.html, and otherwise rewrites to index.php with $_SERVER['SCRIPT_NAME']
reset so the router works out the right base url. It also refuses to serve dotfiles or itself.
docker/app/scripts/serve.bash - what the develop compose service runs - passes it as the
router argument: php -S 0.0.0.0:5000 -t ./src/<App>/Public/ ./src/<App>/Public/dev-router.php.
A real web server uses .htaccess instead.
.htaccess denies dotfiles and common backup/editor extensions outright, sets three
security headers when mod_headers is available, and rewrites anything that is not an
existing file to
index.php/$1 - with one carve-out ahead of it that sends an existing file under Config/
through the router instead of serving it directly, so a request that happens to collide with
that directory name cannot read the application’s config off disk.
assets/src/ vs assets/dist/. assets/src/ is tracked, and every application gets the
same floor from _base: the base/ subtree (scss/theme.scss, and the ts/ config,
polyfill, interfaces and utils), globals.d.ts and index.scss. The flavour preset adds its
own entry point on top - twig adds defaults.ts, react adds app.tsx and a react/
subtree instead. assets/dist/ is where the build output lands and is gitignored
(/src/*/Public/assets/dist/ in .gitignore); it does not exist until you run the asset
pipeline, and neither does assets/fonts/. What builds src/ into dist/, and the npm
scripts that drive it, are
the asset pipeline’s subject, not this one’s.