webimage / application
Application components
Requires
- ext-json: *
- guzzlehttp/psr7: ^2.0
- league/route: ^5.1
- psr/http-message: ^1.1 || ^2.0
- webimage/core: ^1.6.0
Requires (Dev)
- phpunit/phpunit: ^9.6
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Application components for WebImage projects: an HTTP application (router, controllers, views, sessions), a console application (commands, argument/option parsing), a service container, and a plugin system that wires libraries into either one.
Both application types are built from the same config array and boot plugins the same way, so a typical project has one config file and two small entry points — one for the web, one for the command line.
composer require webimage/application
Contents
Directory layout
The recommended layout (the same one used by webimage/app-starter):
my-project/
├── composer.json
├── cli # CLI entry point
├── public/ # Web document root (app.docroot)
│ └── index.php # Web entry point
├── app/ # Project path (see below)
│ ├── config/
│ │ ├── config.php # Application config
│ │ ├── routes.yaml # Route definitions (source)
│ │ └── routes.yaml.php # Compiled routes (generated by routes:compile)
│ ├── src/ # App\ namespace
│ │ ├── Controllers/ # App\Controllers\*Controller
│ │ └── Commands/ # App\Commands\*Command (optional)
│ └── resources/
│ └── views/
│ ├── layouts/
│ │ └── default.php # Default master view
│ └── controllers/ # controllers/<controller>/<action>.php
└── vendor/
The project path is app/, not the repository root. Unless app.path is set in
config, the application locates the top-most composer.json above the library and
appends /app. Relative paths used by the framework resolve from there:
| What | Default location (relative to project path) |
|---|---|
App autoload (App\ → src) |
app/src/ |
| Route source file | app/config/routes.yaml |
| Compiled routes | app/config/routes.yaml.php |
| Views | app/resources/views/ |
Map the App\ namespace in your composer.json as well so the classes are available
to tooling and tests:
{
"require": {
"webimage/application": "^1.3"
},
"autoload": {
"psr-4": {
"App\\": "app/src/"
}
}
}
Configuration
app/config/config.php returns a plain array. Both entry points load it and pass it
to the application, which merges it over the built-in defaults.
<?php // app/config/config.php return [ 'debug' => true, 'app' => [ // Absolute path to the public web root. Required by the `serve` command. 'docroot' => __DIR__ . '/../../public', ], 'plugins' => [ // Plugin class names, e.g. // WebImage\Db\Plugin::class, ], ];
Commonly used keys:
| Key | Default | Purpose |
|---|---|---|
app.path |
<root>/app |
Project path. Override if you use a different layout. |
app.docroot |
— | Public web root; used by serve. |
app.namespace |
App |
Application namespace. |
app.autoload |
['App' => 'src'] |
Extra runtime autoload mappings (relative to project path). |
app.controllers.namespace |
App\Controllers |
Where short controller names (Home) are expanded. |
plugins |
[] |
Plugin classes to register — see Plugins. |
router.* |
see Routes | Route source/compiled file locations. |
views.defaultMasterView |
layouts/default |
Layout that controller views extend. |
views.paths |
[] |
Additional view directories. |
console.commands |
built-ins | Commands to register — see CLI application. |
console.commandDirectories |
[] |
Directories to scan for *Command.php. |
console.commandNamespace |
'' |
Namespace for classes found in commandDirectories. |
console.defaultCommand |
— | Command to run when none is given. |
serviceManager |
— | Container config (providers, shared, invokables). |
Plugins can contribute to any of these keys; their own config/config.php is merged
into the application config when they are initialized.
Web application
public/index.php:
<?php require_once __DIR__ . '/../vendor/autoload.php'; use WebImage\Application\HttpApplication; use WebImage\Config\Config; $config = new Config(require __DIR__ . '/../app/config/config.php'); HttpApplication::create($config)->run();
HttpApplication::create() builds the service container, registers and initializes
plugins, loads the compiled routes, then run() dispatches the current request and
sends the response.
Controllers
Controllers live in app/src/Controllers and usually extend
WebImage\Controllers\AbstractController (directly, or via an app-level base class):
<?php namespace App\Controllers; use WebImage\Controllers\AbstractController; class HomeController extends AbstractController { public function index() { // Renders app/resources/views/controllers/home/index.php // inside app/resources/views/layouts/default.php return $this->view(['title' => 'Home']); } public function status(): array { // Arrays are returned as JSON return ['status' => 'ok']; } }
An action may return:
- a
ViewInterface(e.g.$this->view(...)) — rendered into the response body - a
string— written to the response body as-is - an
array— JSON-encoded withContent-type: application/json - a PSR-7
ResponseInterface— sent as-is (e.g.$this->redirect('/login'))
$this->view($vars, $viewKey = null, $master = null) defaults the view key to
controllers/<controller>/<action> and wraps it in views.defaultMasterView; pass
false as the master to render without a layout. The layout receives the rendered
page as $content:
<!-- app/resources/views/layouts/default.php --> <!doctype html> <html> <head><meta charset="utf-8"><title><?= $title ?? 'Home' ?></title></head> <body> <?= $content ?> </body> </html>
Local development server
With the CLI entry point in place (below), run PHP's built-in server against
app.docroot:
php cli serve # http://localhost:8080
php cli serve --host=0.0.0.0 --port=8000
Routes
Routes are written in YAML and compiled to a PHP array that the web application loads on each request. Compiling keeps YAML parsing out of the request path.
-
Create
app/config/routes.yaml:routes: /: GET: HomeController@index /status: GET: HomeController@status /admin: middleware: [auth] /users/{id}: GET: AdminController@showUser PUT: AdminController@updateUser include: - ./api-routes.yaml
-
Compile it:
php cli routes:compile # writes app/config/routes.yaml.php php cli routes:compile --watch # recompile on change (-w) php cli routes:compile --report # show what's out of date (-r) php cli routes:compile --force # recompile even if up to date (-f)
php cli buildruns the route compiler together with any other registered build steps, andphp cli build --watchwatches all of them. -
HttpApplicationloadsapp/config/routes.yaml.phpautomatically. If the compiled file doesn't exist, no routes are loaded (unlessrouter.requiredistrue, which throws instead).
Handlers use Controller@method. A controller name without a namespace is resolved
against app.controllers.namespace (App\Controllers).
Router options (all optional):
'router' => [ 'autoLoad' => true, // false: load routes yourself via $app->loadRoutesFromFile() 'required' => false, // true: throw if the compiled file is missing 'routeFiles' => [__DIR__ . '/routes.yaml'], // source file(s) 'compiledFile' => __DIR__ . '/routes.yaml.php', ],
See docs/router.md for the full YAML syntax (nesting, middleware, per-method handlers, includes) and docs/routes.sample.yaml for a larger example.
CLI application
cli (in the project root, made executable with chmod +x cli):
#!/usr/bin/env php <?php require_once __DIR__ . '/vendor/autoload.php'; use WebImage\Application\ConsoleApplication; use WebImage\Config\Config; $config = new Config(require __DIR__ . '/app/config/config.php'); exit(ConsoleApplication::create($config)->run());
php cli # brief usage (exit code 2) php cli --help # list all commands php cli <command> --help
ConsoleApplication ships with serve, routes:compile and build. Extend
AbstractConsoleApplication instead if you want a console with no built-in commands.
Writing a command
Extend WebImage\Commands\Command, declare its name, arguments and options in
configure(), and do the work in execute():
<?php namespace App\Commands; use WebImage\Commands\Command; use WebImage\Console\Argument; use WebImage\Console\ConsoleInput; use WebImage\Console\ConsoleOutput; use WebImage\Console\FlagOption; use WebImage\Console\FlexibleOption; use WebImage\Console\ValueOption; class ImportCommand extends Command { protected function configure(): void { $this->setName('data:import') ->setDescription('Import records from a file') ->setGroup('data') ->addArgument(Argument::required('filename', 'The file to import')) ->addArgument(Argument::optional('format', 'Input format', 'json')) ->addOption(FlagOption::create('verbose', 'Enable verbose output', 'v')) ->addOption(ValueOption::required('config', 'Config file path', 'c')) ->addOption(ValueOption::optional('output', 'Output file', 'o')) ->addOption(FlexibleOption::create('loop', 'Loop count, or just enable looping', 'l')); } public function execute(ConsoleInput $input, ConsoleOutput $output): int { $filename = $input->getArgument('filename'); $format = $input->getArgument('format'); // 'json' if not provided $output->info("Importing {$filename} as {$format}"); if ($input->hasOption('verbose')) { $output->writeln('Verbose mode enabled'); } $output->writeln('Using config: ' . $input->getOption('config')); if ($input->hasOption('output')) { $output->writeln('Writing to: ' . $input->getOption('output')); } if ($input->hasOption('loop')) { $input->isOptionFlag('loop') ? $output->writeln('Looping enabled') : $output->writeln('Looping ' . $input->getOption('loop') . ' times'); } $output->success('Done'); return 0; // exit code } }
php cli data:import records.json --config=app.json php cli data:import records.csv csv -c app.json -v --output=result.txt php cli data:import records.json -c app.json --loop # flag form php cli data:import records.json -c app.json --loop 5 # value form
Option types:
| Class | Usage |
|---|---|
FlagOption::create($name, $desc, $short) |
Boolean switch: --verbose / -v |
ValueOption::required($name, $desc, $short) |
Must be given a value: --config=path |
ValueOption::optional($name, $desc, $short, $default) |
Value optional, with default |
FlexibleOption::create($name, $desc, $short, $default) |
Either a flag or a value; check with isOptionFlag() |
ConsoleInput also offers interactive helpers — ask(), askHidden(), choice(),
confirm() — and ConsoleOutput has info(), success(), warning(), error()
and progressBar().
Commands are container-aware: $this->getContainer() returns the service container
inside execute(). Implement ConfigurableCommandInterface and its
configureCommand() method to resolve dependencies once the container is attached.
Registering commands
Either list them in config:
'console' => [ 'commands' => [ App\Commands\ImportCommand::class, 'import' => App\Commands\ImportCommand::class, // string key overrides the command name ], ],
or have them discovered from a directory (every non-abstract *Command.php class):
'console' => [ 'commandDirectories' => [__DIR__ . '/../src/Commands'], 'commandNamespace' => 'App\\Commands', ],
Plugins register their commands the same way, from their own config/config.php.
Plugins
A plugin extends WebImage\Application\AbstractPlugin and is the unit that wires a
library into an application. Plugins are booted the same way for every application
type (console and web).
Adding plugins to an application
Install the package with Composer, then list its plugin class under plugins in
app/config/config.php:
'plugins' => [ WebImage\Db\Plugin::class, WebImage\Models\Plugin::class, ],
Each class is instantiated and registered when the application is constructed, then
all registered plugins are initialized before the application runs. If a plugin
declares a required plugin that isn't registered (or is too old), boot fails with
PluginNotFoundException / PluginRegisteredException, so add dependencies here too.
Registering the same plugin twice also throws.
A plugin can also be registered in code with $app->registerPlugin(new Plugin()),
including from another plugin's load().
Writing a plugin
A plugin package has a plugin.json manifest at its root (found by walking up from
the plugin class's file) and, optionally, a config/config.php next to it:
my-plugin/
├── plugin.json
├── config/
│ └── config.php # merged into the app config during init()
└── src/
└── Plugin.php
{
"id": "vendor/my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"description": "What it does",
"author": {"name": "Jane Doe", "email": "jane@example.com"},
"requirements": {
"plugins": {"webimage/db": "1.0.0"}
}
}
id, name, version, and author (or an authors array) are required; each author
needs name and email. requirements.plugins maps plugin ids to minimum versions.
Lifecycle
| Phase | Method | When it runs | What it is for |
|---|---|---|---|
| Register | (config plugins list / $app->registerPlugin()) |
Before boot | Make the plugin known. A plugin may register further plugins during load(). |
| Init | AbstractPlugin::init() |
Every request / command | Merges the plugin's config/config.php into the app config, applies its serviceManager config key, then calls load(). Provided by AbstractPlugin; do not override. |
| Load | AbstractPlugin::load() |
Every request / command | Wiring only — see below. This is the method you implement. |
| Install | PluginInterface::install() |
Explicit setup step only | One-time provisioning: create schema, seed data, publish assets. Never runs implicitly during a normal request or command. |
| Uninstall | PluginInterface::uninstall() |
Explicit teardown step only | Reverse of install(). |
load() runs on every console command and every web request, including on a
brand-new project whose database, schema, and models have not been set up yet. Write
it accordingly.
The rule: load() wires plumbing, it does not do work
load() should only describe how things are put together. It must not use them.
Belongs in load():
$app->getServiceManager()->addServiceProvider(new SomeServiceProvider())- Registering lazy container definitions (
add()/addShared()with a factory closure) - Registering routes, but only for the right application type
(
if (!($app instanceof HttpApplication)) return;) and behind any config flag - Registering event listeners, console commands, template parsers, and other declarations that are cheap to create and are not exercised until later
- Merging configuration
Does not belong in load():
- Querying a database, cache, filesystem index, search engine, or HTTP API
- Reading application data of any kind — rows, stored definitions, uploaded files
- Instantiating objects that open connections or do I/O in their constructor
- Anything that assumes
install()/ model setup has already run - Expensive computation that most requests will not need
If load() touches a backing store, every command breaks until that store exists —
including the very *:setup / models:sync command you need to run to create it.
Do it through a service provider, lazily
Register a factory and let the container build the object the first time something actually asks for it. Nothing is instantiated during boot.
class Plugin extends AbstractPlugin { protected function load(ApplicationInterface $app): void { $sm = $app->getServiceManager(); if ($sm === null) return; $sm->addServiceProvider(new WidgetServiceProvider()); } } class WidgetServiceProvider extends AbstractServiceProvider { protected array $provides = [WidgetRepository::class]; public function register(): void { // Factory closure — not called until WidgetRepository::class is resolved. $this->getContainer() ->addShared(WidgetRepository::class, function (RepositoryInterface $repo) { return new WidgetRepository($repo); }) ->addArgument(RepositoryInterface::class); } }
A consumer (controller, command, listener) then pulls WidgetRepository from the
container at the point of use, which is also the point at which "is the store ready?"
has a meaningful answer.
If you genuinely must read data during load()
Prefer moving the work to first-use instead (a lazy hook on the service that needs it). When that is not practical, the read must degrade gracefully rather than fatal:
- Ask a cheap question first.
App-ModelsexposesRepositoryInterface::isModelProvisioned(string $model): boolfor exactly this — it checks table presence without running the query that would throw. - On "not provisioned", do nothing (register zero stored items) and return. A missing store on a fresh project is a normal state, not an error.
- For a user-facing explanation,
RepositoryInterface::getMissingStorage(string $model)returns kind-tagged gap objects whosegetDescription()is safe to print or log. - Do not wrap the whole
load()body in a broadtry/catch. If you catch at all, catch one specific, typed exception at one call site — a broad catch also hides a genuinely misconfigured connection.
Schema creation itself belongs in install() / the plugin setup path, never in
load().