Skip to content

Framework & Architecture / One request, no controller

From URL to HTML: Following a Semitexa Request

By ·

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.

PHP · a typical controller action for the same page (illustrative, not Semitexa code)
#[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 jobSemitexa ownerIn this request
Route, method, content typePayload attribute#[AsPublicPayload(path: '/blog/{slug}')]
Read and clean inputPayload setterssetSlug(), setScenario()
Decide what it means, or 404HandlerBlogArticleHandler::handle()
Shape the response dataResourceBlogArticleResource::withArticle()
Turn data into HTMLTemplateblog-request-to-html.html.twig
Build the HTTP responseFrameworkStatus, 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

  1. 01PayloadNames the inputslug
  2. 02HandlerChooses the articlemetadata + view
  3. 03ResourceShapes the responsearticle + SEO
  4. 04TemplateMakes it readableHTML

OUTPUT The article you are reading 200 OK

This is the real article route in 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:

  1. 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.
  2. 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.
  3. Access. AsPublicPayload means anonymous access is allowed. A protected payload would require an authenticated user before the payload is even filled.
  4. Hydration. Semitexa creates the payload and calls one setter per input value. Path parameters win over body and query values, so ?slug=other cannot replace the slug from the URL.
  5. 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.