Clean Framework · DOCUMENTATION
Complete application walkthrough: Reading List
Complete application walkthrough: Reading List
Bounded candidate reference — 2026-10-20: 0.2.0-rc.1 is committed locally through 84f415b3098af5653577b2c65eea31fca21df79c, unpublished and not production-approved; stable PHP >=8.5.11 <8.6.0 is required. This is documentation, not adoption: this knowledge base retains its installed 0.1.0-rc.1 dependency and PHP 8.2.11 local profile. Dated PHP 8.2 evidence is historical; earlier dirty-source reports retain their original test provenance, not retroactive clean-commit proof. Candidate examples are illustrative: API signatures were reviewed, but candidate code was not executed by this editorial update. See current checkpoint, open criteria and adoption boundary.
Current runtime: stable PHP>=8.5.11<8.6.0. The host8.2 interpreter is not usable for this candidate. Run only in an already approved runtime; local examples are not permission to download dependencies or access another application. For new-skeleton setup use the release-matched recipe.
This walkthrough follows the existing, runnable reading-list application. It demonstrates the complete configuration-to-view path without a database, credentials, provider, or compatibility bridge. Snippets below are source excerpts, not replacement files; open the linked file for all imports and composition.
For a new product, start from the skeleton checklist. The reading list is a small reference application, not a second generic scaffold.
1. Understand the application boundary
public/app.php: load/validate configuration, compose kernel, create request
-> resources/routes.php: select controller method and constrained slug
-> ReadingListController: translate HTTP to the browsing use case
-> BrowseReadingList: request domain records through a repository interface
-> InMemoryReadingListRepository: return explicit application-owned records
-> controller: supply presentation arrays to ViewFactory
-> layouts/main.php and pages/*.php: escape and render native PHP
-> public/app.php: send one Response, then terminate the kernelOnly public/ is web-visible. Configuration, domain code, views, dependencies, and storage remain outside it. The application namespace is Portability\ReadingList\, not Clean\Framework\.
2. Load configuration before composing services
The front controller obtains $root with dirname(__DIR__), loads Composer, and then runs this configuration step:
// Imports belong at the top of public/app.php.
use Clean\Framework\Config\EnvironmentSource;
use Clean\Framework\Config\Loader;
// $root is the application's directory; no request or provider is needed here.
$loader = new Loader(EnvironmentSource::fromRuntime($root . '/.env'));
$schema = require $root . '/config/schema.php';
$config = $loader->load($root . '/config/app.php', $root . '/config/environments', [
'app.name' => ['APP_NAME', 'string'],
'app.version' => ['APP_VERSION', 'string'],
'app.release_id' => ['APP_RELEASE_ID', 'string'],
'app.url' => ['APP_URL', 'string'],
'app.timezone' => ['APP_TIMEZONE', 'string'],
'app.debug' => ['APP_DEBUG', 'bool'],
'http.base_path' => ['APP_BASE_PATH', 'string'],
'http.canonical_path_mode' => ['HTTP_CANONICAL_PATH_MODE', 'string'],
'http.trusted_proxies' => ['TRUSTED_PROXIES', 'string'],
'http.max_body_bytes' => ['HTTP_MAX_BODY_BYTES', 'int'],
'http.max_files' => ['HTTP_MAX_FILES', 'int'],
'http.max_upload_bytes' => ['HTTP_MAX_UPLOAD_BYTES', 'int'],
'http.json_depth' => ['HTTP_JSON_DEPTH', 'int'],
], schema: $schema);Read defaults, schema, and the independent DEV, TEST, STAGE, and PROD overlays together. APP_ENV is required; a shell setting overrides the local .env selection. There are no database or provider credential requirements in this application.
3. Keep domain data and persistence application-owned
ReadingItem validates a slug, title, and author and knows nothing about HTTP or templates. ReadingListRepository defines application concepts:
// These signatures are members of the application repository interface.
/** @return list<ReadingItem> Return the ordered reading list. */
public function all(): array;
/** Return one exact entry, or null when it does not exist. */
public function findBySlug(string $slug): ?ReadingItem;The in-memory adapter indexes an explicit duplicate-free fixture by slug. It persists nothing across requests. In a real application, implement the same interface using a database adapter and bound SQL values, then replace one composition binding. Do not add database calls to templates or conceal them in the framework bootstrap.
4. Coordinate the browsing use case in a service
BrowseReadingList receives the repository through constructor injection. Its all() method maps domain objects to presentation arrays, and find() returns one array or null:
// Members of BrowseReadingList; ReadingItem is imported in the source file.
/** Return one presentation record, or null for an unknown exact slug. */
public function find(string $slug): ?array
{
return $this->repository->findBySlug($slug)?->toArray();
}This example has no mutation or transaction. A mutating service would own the transaction and any outbox write; the controller still only translates HTTP.
5. Bind dependencies explicitly, then freeze composition
The composition root returns a closure accepting Config $config and string $root. It creates the container, logger, view factory, repository, router, and kernel without performing network or database work. The relevant bindings are:
// Container, ViewFactory, and the application types are imported by bootstrap.php.
// $repository, $views, and $container have been constructed immediately above.
$container->instance(ReadingListRepository::class, $repository);
$container->singleton(BrowseReadingList::class, static fn (Container $services): BrowseReadingList =>
new BrowseReadingList($services->make(ReadingListRepository::class))
);
$container->factory(ReadingListController::class, static fn (Container $services): ReadingListController =>
new ReadingListController(
$services->make(BrowseReadingList::class),
$services->make(ViewFactory::class)
)
);
$container->freeze();The immutable fixture-backed service can be a singleton. Use scoped services for request/job-specific mutable state; do not retain a current request or principal inside a singleton. Register every override before freezing.
6. Declare routes and HTTP behavior
resources/routes.php returns a registration closure and is loaded explicitly by bootstrap.php:
// Inside the registration closure; Router and controller imports are in the file.
$router->get('/', ReadingListController::class . '::index')->name('reading.index');
$router->get('/reading/{slug}', ReadingListController::class . '::show')
->where('slug', 'slug')
->name('reading.show');The controller injects BrowseReadingList and ViewFactory. Its index method returns HTML:
// Member of ReadingListController; Request and Response are imported there.
/** Render supplied presentation data rather than querying inside a template. */
public function index(Request $request): Response
{
return Response::html($this->views->render('layouts/main.php', [
'title' => 'Reading List',
'content_view' => 'pages/index.php',
'items' => $this->readingList->all(),
]));
}show() reads $parameters['slug'], asks the service for a record, and throws a client-safe HttpException(404, ...) when absent. The route constraint checks the slug's shape, not existence or authorization. These browsing routes are intentionally public; a private product must add its own route middleware.
7. Render and escape native PHP views
The layout renders the controller-selected pages/index.php using $render($content_view), with the current data inherited by the partial. Neither a query parameter nor an uploaded filename selects a template.
The list template iterates $items; this is its output boundary:
<a href="/reading/<?= $e(rawurlencode($item['slug'])) ?>">
<?= $e($item['title']) ?>
</a>
<span>by <?= $e($item['author']) ?></span>$e escapes HTML; rawurlencode() first encodes the slug as one URL segment. The example's literal links assume http.base_path=''. If deploying below a prefix, pass named URLs from the router into presentation data and update all links and asset URLs together. HTML escaping is not JavaScript/CSS escaping.
8. Run deterministic checks before serving it
From the framework root, the existing test runner can exercise the example through the root autoloader without installing or serving the example:
cd "/Users/dan/Documents/CleanFramework Project/CleanFramework"
composer test
php examples/reading-list/tests/run.php
composer verifyThe portability runner reports six checks: output-free composition, index/security headers, detail data, safe missing-record handling, health, and domain validation. It does not prove Apache, real databases, or providers.
To serve the example, install its own local dependency and run a local-only development server in a separate terminal:
cd "/Users/dan/Documents/CleanFramework Project/CleanFramework/examples/reading-list"
COMPOSER_DISABLE_NETWORK=1 composer install --no-plugins --no-scripts --no-interaction
APP_ENV=TEST php -S 127.0.0.1:18089 -t public public/dev-router.phpIn another terminal, check /, /reading/practical-php, /reading/not-present, /health, and /assets/css/app.css on http://127.0.0.1:18089. Expected statuses are 200, 200, 404, 200, and 200 respectively. Stop the server with Ctrl-C. The development router serves static assets; this is not Apache verification.
9. Extend safely
- Add a setting in defaults, every relevant overlay, the schema, and the front-controller/CLI bindings before accessing it in a service.
- Add use cases and repository methods in the application namespace; add controller actions, explicit factories, and named routes alongside them.
- Bind shared infrastructure through ports and test the selected adapters.
- Add negative tests for malformed input, missing records, unauthorized access, and safe failures rather than checking only a happy page.
- Preserve native PHP templates and the single response-sending boundary.
See API_GUIDE.md for semantics and TROUBLESHOOTING.md for failure diagnosis. Real migrations require an explicitly authorized disposable database and the separate database guide; this walkthrough does not run them.
Editorial provenance
Source: docs/APPLICATION_WALKTHROUGH.md. Source SHA-256: 4e8d37247684e53685ad81045fcb4fc04c69de71462027e811f41ebfbb1955fc.
Framework candidate reference; editorial checkpoint 2026-10-20. See candidate status and adoption boundary.