Web basics

Views

When the server returns finished HTML — an admin panel, a page for a crawler, an email — Winter renders it from ordinary PHP templates through ResponseView. There are layouts, partials and a few helpers; data reaches the template as variables.

Response type ResponseViewTemplates resources/viewsContent-Type text/html

What views are and why

A view is a template the response HTML is assembled from.

The problem. Building markup in a controller by concatenating strings is hard to read and easy to break: HTML mixes with logic, escaping gets forgotten, and the page shell has to be repeated in every method — where it starts diverging with the very first edit.

The solution. Keep markup in separate PHP files and return a ResponseView from the controller with a template name and data. The shell moves into a layout; repeated fragments into partials.

Winter is API-first

If your application returns JSON you do not need views: that is ResponseEntity — see Responses. This page is about the case where the server assembles HTML.

Two ways to render

The difference between them is whether there is a shared page shell.

php
use Flytachi\Winter\Kernel\Http\Response\ResponseView;

// one template, as is
return ResponseView::view('user/profile', ['user' => $user]);

// a template nested inside a layout
return ResponseView::render('layouts/main', 'user/profile', ['user' => $user]);

In render() the layout comes first and the resource — the page itself — second. The order is easy to mix up and the error will merely say a file was not found, so remember it as outside-in.

What What it is Where it usually lives
Resource The page: the content the request came for views/user/profile.php
Layout The shell: <html>, header, footer, stylesheet links views/layouts/main.php
Partial A fragment embedded into others views/partial/nav.php

There is no technical difference between them — all three are ordinary .php files under one root; only their role differs.

A typical page-serving controller:

main/PageController.php
#[RequestMapping('cabinet')]
class PageController extends Controller
{
  #[Autowired] private UserService $service;

  #[GetMapping('profile')]
  public function profile(): ResponseView
  {
      return ResponseView::render('layouts/main', 'user/profile', [
          'title' => 'Profile',
          'user'  => $this->service->current(),
      ]);
  }
}

Where templates live

By default in a views directory inside the project’s resources, that is resources/views. Nothing needs configuring: the name user/profile becomes resources/views/user/profile.php, the extension is appended for you.

The root can be changed once, at application startup:

php
ResponseView::setBasePath(__DIR__ . '/theme');

That is rarely needed — for instance when a template theme ships as its own package.

Templates are not scanned — and that is correct

resources/ is deliberately excluded from the class scan: a template is a PHP file, and the scanner, finding a class declaration in it, would load it at boot — that is, execute the markup. Keep templates in resources/ and classes outside it.

Template data

Every key of the array you pass becomes a variable in the template:

resources/views/user/profile.php
<h1><?= htmlspecialchars($user->name) ?></h1>
<p>Joined: <?= $user->createdAt->format('d.m.Y') ?></p>

The same data is visible in the layout and in partials — you do not have to pass it onwards by hand.

Alongside them there is always $data, the whole array. Useful when the keys are not known in advance, or when they have to be handed on in bulk:

resources/views/partial/debug.php
<?php foreach ($data as $key => $value): ?>
  <li><?= htmlspecialchars($key) ?></li>
<?php endforeach; ?>

The name data is taken

A data key never reaches the template: the $data variable always means the whole array. Every other name is free — path, content and title included.

Escaping is on you

Winter escapes nothing: a template is plain PHP, and <?= $var ?> prints the value as it is. Wrap anything that came from a user in htmlspecialchars().

Template helpers

Four global functions are available inside templates.

Function What it does
wrContent() Prints the rendered resource — called in the layout
wrImport('partial/nav') Includes another template right here
wrData('title') The value by key; with no argument, the whole data array
wrIsActiveLink('/cabinet') Returns a CSS class when this is the current address

A complete layout looks like this:

resources/views/layouts/main.php
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title><?= htmlspecialchars(wrData('title') ?? 'Cabinet') ?></title>
</head>
<body>
  <?php wrImport('partial/nav'); ?>

  <main>
      <?php wrContent(); ?>
  </main>
</body>
</html>

wrContent() is where the page goes. Without that call the layout renders empty: the resource is built before the layout, and there is nothing else to insert it.

wrData() and a plain variable are the same value; the function is handy where the key may be absent, because it returns null instead of an undefined-variable error.

Highlighting the current menu item

wrIsActiveLink() compares its argument with the path of the current request and returns one of two strings. The match is exact, but the query string is ignored: on /cabinet/orders?page=2 the /cabinet/orders item stays highlighted.

resources/views/partial/nav.php
<nav>
  <a class="<?= wrIsActiveLink('/cabinet/profile') ?>" href="/cabinet/profile">Profile</a>
  <a class="<?= wrIsActiveLink('/cabinet/orders') ?>" href="/cabinet/orders">Orders</a>
</nav>

By default it returns active on a match and an empty string otherwise; both classes are overridden by the second and third arguments. The first argument also accepts an array of addresses — then the item counts as active on any of them:

php
wrIsActiveLink(['/cabinet/orders', '/cabinet/orders/archive'], 'is-current', 'is-muted')

Status code and headers

ResponseView is a builder like every other response: the third argument is the HTTP status, headers are chained.

php
use Flytachi\Winter\Base\HttpCode;

return ResponseView::view('errors/404', ['path' => $path], HttpCode::NOT_FOUND)
  ->header('Cache-Control', 'no-store');

The response always goes out with Content-Type: text/html; charset=utf-8.

Next