feat: closure-based view system#6557
Open
gharlan wants to merge 6 commits into
Open
Conversation
A typed, statically analysable view layer in src/View, alongside the existing Fragment system (which is left untouched — no fragments are converted here): - Html: typed safe-HTML value object and itself a Renderable — Html::from() escapes plain strings / renders any Renderable, Html::raw() wraps trusted markup, Html::capture() captures a closure's output for inline HTML. - Renderable / RendersView / HasView: a class renders a co-located ClassName.view.php (a file returning a typed, inert `static` closure); the whole object is handed to the view. render(): Html, no $this/scope-this magic. - Renderer: internal engine that requires the view file and invokes the closure in an output buffer (cleaned up on throw). - ViewResolver: identity-based override — a project/addon can replace any class's view by FQCN (ViewResolver::override(Foo::class, $file)), last registration wins. Content properties take Renderable, so callers can pass sub-components directly without ->render(); slots are covered by typed optional Renderable properties + named arguments. The project starter's DefaultTemplate ships a co-located DefaultTemplate.view.php as the intended authoring pattern. Test fixtures live in tests/View/Fixtures. Verified green under phpunit, php-cs-fixer, phpstan and psalm/taint.
Addresses PR review feedback: - mark Html::raw() as an HTML taint sink (@psalm-taint-sink html): it is the explicit trust boundary, so feeding tainted input is now reported by the taint analysis (the in-view echo is severed from taint tracking by output buffering, so this is where it is caught). Html::from() escapes/renders and needs no sink. - add explicit Closure signatures (Closure(): void / Closure(mixed...): void) so the code is ready for phpstan's missingType.callable - Renderer::capture() uses Type::string(ob_get_clean()) instead of a soft cast - reword the Renderable docblock (drop the speculative templates/modules note) - trim the DefaultTemplate comment Green: phpunit, php-cs-fixer, phpstan (incl. checkMissingCallableSignature on src/View), psalm/taint.
Use Closure():void / Closure(mixed...):void in PHPDoc (no space before the return type), independent of code-level spacing.
staabm
reviewed
Jun 18, 2026
| <head> | ||
| <meta charset="utf-8"> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| <title><?= escape($content->article->name) ?> – <?= escape(Core::getServerName()) ?></title> |
Member
There was a problem hiding this comment.
might be worth doing the inverse. escape everything per default and add a helper function to work with a raw value.
that way you get XSS protection by default
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Introduces a typed, statically analysable view layer in
src/View, alongside the existingFragmentsystem (which is left untouched — no fragments are converted in this PR). It is a first version; details may still change before the 6.0 release.Motivation
The current
Fragmentsystem passes an untyped var-bag into a template that runs in a$thisscope (registered as a PHPStan/Psalm universal object crate), so nothing about a fragment's inputs is statically checked — call sites don't know which vars exist, which are required or what types they have, templates need@var/@psalm-scope-thisboilerplate, and the optional escape flag is opaque to taint analysis.What's in here
Html— typed safe-HTML value object, itself aRenderable.Html::from()escapes a plain string / renders anyRenderable;Html::raw()wraps trusted markup;Html::capture()captures a closure's output so callers can write real inline HTML. The escaping decision lives in the type, not a runtime flag.Renderable/RendersView/HasView— a class renders a co-locatedClassName.view.php: a file that returns a typed, inertstaticclosure receiving the whole object.render(): Html, no$this/scope-this magic, fully type-checked, runtime-enforced.Renderer— internal engine thatrequires the view file and invokes the closure inside an output buffer (cleaned up on throw).ViewResolver— identity-based override: a project or addon can replace any class's view by FQCN (ViewResolver::override(Foo::class, $file)), last registration wins.Content properties take
Renderable, so sub-components can be passed directly without calling->render(). "Slots" are covered by typed optionalRenderableproperties + named arguments (no dedicated slot runtime). The project starter'sDefaultTemplateships a co-locatedDefaultTemplate.view.phpas the intended authoring pattern.Example
A component is a small typed class plus a co-located view file (illustrative — this PR ships the base; a real use is the project starter's
DefaultTemplate):Badge.php— the constructor is the statically-checked contract:Badge.view.php— co-located (same base name), returns a typed closure that receives the object:Call site — typed, named arguments, no var-bag:
$badge->label/$badge->typeare fully analysed in the view, andescape()keeps output safe. Sincerender()returnsHtml(which isRenderable), aBadgecan also be passed straight into another component's content property — no manual->render():Deliberately out of scope (follow-ups)
claude/view-system-fragments; will likely be restructured conceptually rather than translated 1:1).HtmlAttributeshelper for attribute handling in views.Verification
Green under
phpunit,php-cs-fixer,phpstan(level 6) andpsalm/taint. Covered by tests intests/View(Html,Renderer,RendersView,ViewResolver, slot pattern).🤖 Generated with Claude Code