Search by

webimage / application

WebImage

Application components

Package info

github.com/WebImage/Application

pkg:composer/webimage/application

Statistics

Installs: 78

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.6.0 2025-10-31 22:28 UTC

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

  1. Directory layout
  2. Configuration
  3. Web application
  4. Routes
  5. CLI application
  6. Plugins

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 with Content-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.

  1. 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
  2. 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 build runs the route compiler together with any other registered build steps, and php cli build --watch watches all of them.

  3. HttpApplication loads app/config/routes.yaml.php automatically. If the compiled file doesn't exist, no routes are loaded (unless router.required is true, 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-Models exposes RepositoryInterface::isModelProvisioned(string $model): bool for 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 whose getDescription() is safe to print or log.
  • Do not wrap the whole load() body in a broad try/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().