Framework & Architecture / One request, no controller
From URL to HTML: Following a Semitexa Request
In most PHP frameworks, the URL of this page would end in a controller method. Semitexa serves it without one. Four small pieces each own one job: Payload → Handler → Resource → Template. Follow the value that crosses each boundary and you can see where every line of the usual controller went.
The controller you would normally write
Here is a fair, conventional version of this page as a single controller action. Nothing in it is wrong. It is the shape most of us have written many times.
#[Route('/blog/{slug}', methods: ['GET'])]
public function show(Request $request, string $slug): Response
{
// 1. Read and clean the input
$scenario = $request->query->get('scenario', 'boundary');
if (!in_array($scenario, ['below', 'boundary', 'above'], true)) {
$scenario = 'boundary';
}
// 2. Decide what the request means
$article = $this->catalog->findByPath('/blog/' . $slug);
if ($article === null) {
throw new NotFoundHttpException();
}
// 3. Assemble the response
$html = $this->twig->render('blog/' . $article['template'], [
'article' => $article,
'scenario' => $scenario,
]);
return new Response($html, 200, ['Content-Type' => 'text/html']);
}
One method reads the transport, cleans input, makes the business decision, picks a view and builds the HTTP response. Those jobs change for different reasons: a new query parameter, a new rule, a new template, a JSON variant. In a controller they share one method, so every change touches the same place and every test has to build a Request.
Semitexa keeps the same work and gives each job its own owner:
| Controller job | Semitexa owner | In this request |
|---|---|---|
| Route, method, content type | Payload attribute | #[AsPublicPayload(path: '/blog/{slug}')] |
| Read and clean input | Payload setters | setSlug(), setScenario() |
| Decide what it means, or 404 | Handler | BlogArticleHandler::handle() |
| Shape the response data | Resource | BlogArticleResource::withArticle() |
| Turn data into HTML | Template | blog-request-to-html.html.twig |
| Build the HTTP response | Framework | Status, headers and body from the resource |
See the whole page in one glance
The browser asks for /blog/from-url-to-html. Semitexa gives that request a typed shape, lets the handler find the article, collects the response data and renders it through a shared layout.
One request / four responsibilities
A URL becomes a page.
INPUT GET /blog/from-url-to-html
- 01PayloadNames the input
slug - 02HandlerChooses the article
metadata + view - 03ResourceShapes the response
article + SEO - 04TemplateMakes it readable
HTML
OUTPUT The article you are reading 200 OK
semitexa/site. The sections below show the code that owns each handoff.What Semitexa does before your code runs
A controller framework needs a router and a place to register routes. Semitexa reads the same facts from attributes on your classes:
- Discovery, once per worker. When a worker starts, Semitexa scans the attributes and builds its route and handler registries. There is no routes file, and nothing is rescanned per request.
- Route match.
/blog/{slug}compiles to a pattern. An exact path would win over it; a known path with the wrong method gets a 405. - Access.
AsPublicPayloadmeans anonymous access is allowed. A protected payload would require an authenticated user before the payload is even filled. - Hydration. Semitexa creates the payload and calls one setter per input value. Path parameters win over body and query values, so
?slug=othercannot replace the slug from the URL. - Handler lookup. The registry finds every handler declared for this payload and resource pair and calls it with both objects.
All of that is framework work. The four pieces below are the only code this page adds.
01 / Payload · Name the input
The URL becomes a typed request
BlogArticlePayload declares that GET /blog/{slug} produces HTML and answers with a BlogArticleResource. The path segment arrives through setSlug(). Optional query values arrive the same way, and each setter decides what a valid value is.
BlogArticlePayload.php · the route and the values this page accepts (trimmed)#[AsPublicPayload(
path: '/blog/{slug}',
methods: ['GET'],
responseWith: BlogArticleResource::class,
produces: ['text/html']
)]
final class BlogArticlePayload
{
private string $slug = '';
private string $scenario = 'boundary';
public function setSlug(string $slug): void { $this->slug = $slug; }
public function getSlug(): string { return $this->slug; }
public function setScenario(string $scenario): void
{
$this->scenario = in_array($scenario, ['below', 'boundary', 'above'], true)
? $scenario
: 'boundary';
}
}
Compare it with step 1 of the controller. The scenario whitelist is the same code, but it lives next to the field it protects. When a value must be rejected rather than corrected, the setter throws a ValidationException and Semitexa answers with 422 before any handler runs.
Hands to HandlerBlogArticlePayload → slug: "from-url-to-html"
02 / Handler · Make the decision
The slug becomes an article
BlogArticleHandler receives the typed payload and a resource that Semitexa has already created for it. It asks BlogArticleResolver for the slug. An unknown slug throws NotFoundException, which becomes a 404 page. For this request, the main path reads like this:
BlogArticleHandler.php · the request path, with other article variants left out#[AsPayloadHandler(
payload: BlogArticlePayload::class,
resource: BlogArticleResource::class
)]
final class BlogArticleHandler implements TypedHandlerInterface
{
#[InjectAsReadonly]
protected BlogArticleResolver $articles;
public function handle(
BlogArticlePayload $payload,
BlogArticleResource $resource
): BlogArticleResource {
$resolved = $this->articles->resolve($payload->getSlug());
$article = $resolved->metadata;
return $resource
->pageTitle($article['title'] . ' — Semitexa')
->seoTag('description', $article['description'])
->withArticle($article)
->renderArticle($resolved->template);
}
}
Look at what is missing. The signature names no Request and no Response; Semitexa rejects a handler that returns a raw HTTP response. The handler cannot read a header by accident or set a status code on the side. It gets typed input and fills a typed output, which is what makes it the one place to read when you want to know what this page decides.
The lifetimes are explicit too. #[InjectAsReadonly] gives the handler one resolver shared by the worker. The handler itself is a fresh clone for every request, so nothing it holds leaks into the next visitor's page.
Hands to Resourcetitle + description + article + template
03 / Resource · Shape the response
The article becomes response data
BlogArticleResource is the contract for what goes out. Each with…() method adds one named value to the render context; withArticle() exposes the article to Twig as article.
BlogArticleResource.php · the two methods this request uses#[AsResource(
handle: 'site_blog_article',
template: '@project-layouts-semitexa-site/pages/blog-article.html.twig'
)]
final class BlogArticleResource extends HtmlResponse
implements ResourceInterface
{
public function withArticle(array $article): self
{
return $this->with('article', $article);
}
public function renderArticle(string $template): self
{
$this->disableAutoRender();
return $this->renderTemplate($template);
}
}
Most resources never call a render method. #[AsResource(template: …)] names the view, and Semitexa renders it after the handler returns. The blog is the exception: every article has its own template, so renderArticle() renders the one the resolver chose. Metadata and template come from the same catalog entry, so the view and its data cannot drift apart.
Hands to Templatearticle.title + article.description + article.path
04 / Template · Make it visible
The response becomes a page
The article template reads the resource's article value. It extends the shared blog layout, which supplies navigation, styling and the canonical URL. The result is the HTML the browser displays: this heading, this story and the site around it.
blog-request-to-html.html.twig · the article inside the shared layout{% extends '@project-layouts-semitexa-site/layouts/blog.html.twig' %}
{% block blog_main %}
<article class="blog-article">
<h1>{{ article.title }}</h1>
</article>
{% endblock %}
Semitexa then turns the resource into the HTTP response: status, text/html and the rendered body. That was the last three lines of the controller, and no page code writes them here.
Returns to browser200 OK · text/html · a complete page
When a controller is enough
The split has a cost. One controller method becomes a payload, a handler and a resource, and a throwaway endpoint does not need that. For a health check or a one-off redirect, fewer files is the right call.
The split pays off once the jobs start changing on their own schedules. This blog is a small example: one article runs a live runtime probe, another a shipping-rule lab driven by query values. Each variant added a setter to the payload or a with…() method to the resource. None of them had to reshape how the input is read or how the response is built, because those were never in the handler to begin with.
The shape to remember
Four boundaries. No controller.
Payload tells Semitexa what came in. Handler decides what it means. Resource states what goes out. Template makes that result visible. The router, the hydration and the HTTP response are the framework's job.
When a page changes, the question "where does this belong?" has one answer: new input goes to the payload, a new rule to the handler, new data to the resource, new markup to the template. The request stops feeling like framework machinery and starts reading like the feature it serves.