PHP's reputation as a poor templating choice comes mostly from code that mixes SQL queries, business logic, and markup in the same file. That mixing is a discipline problem rather than a language flaw, and plain PHP can support a basic Model-View-Controller split without adding a templating dependency. Reasons to go this route include legacy projects where new dependencies are unwelcome, small projects that should stay lightweight, or situations where the tooling decision is out of your hands.

Why templating engines exist

PHP began as a templating layer. Rasmus Lerdorf wanted a better way to emit dynamic HTML than writing it in C, which was tedious for the task, while still allowing C for back-end logic. PHP later grew into a general-purpose language. Its ability to switch between programming mode and HTML mode is convenient, but also invites files that open with markup and then drop into an advanced SQL query, which is hard to read and makes template reuse difficult.

As the web community moved toward strict MVC structures, templating engines emerged to separate views from controllers. They share two properties:

  1. They are deliberately underpowered for business logic. A developer who needs a database query must run it in the controller and pass the result into the template; querying from the middle of the markup is not an option.
  2. They handle common security risks behind the scenes. Even when unvalidated user input is passed straight through, the template generally escapes dangerous HTML automatically.

Both properties make for more maintainable and more secure code. Plain PHP can be organized to get most of the way there, occupying a middle ground between spaghetti-coded templating and the no-logic-allowed style of formal engines. The example below uses WordPress, which does not enforce a rigid MVC structure by default — a full templating engine would be heavy-handed here — but the technique applies to a plain PHP environment or many others. No WordPress familiarity is required.

The target is a grid of cards, each showing the title, excerpt, and author of a recent post. Views are split into components, and business logic is kept away from the HTML.

Fetching and preparing data

Data can come from a SQL query, a framework or CMS helper, an ORM, an HTTP call to an external API, or user input from a form or query string. Here the WordPress get_posts helper supplies the homepage posts:

<?php // index.php
$wp_posts = get_posts([
  'numberposts' => 3
]);

The result cannot go straight to the view. get_posts returns an array of WP_Post objects that carry the title, excerpt, and author, but coupling a view to WP_Post would prevent the same card component from rendering other shapes of data elsewhere. Converting each object to a neutral associative array solves this:

<?php // index.php
$wp_posts = get_posts([
  'numberposts' => 3
]);

$cards = array_map(function ($wp_post) {
  return [
    'heading' => $wp_post->post_title,
    'body' => $wp_post->post_excerpt,
    'footing' => get_author_name($wp_post->post_author)
  ];
}, $wp_posts);

Each WP_Post is mapped with array_map. The keys are deliberately general — heading, body, and footing rather than title, excerpt, and author — because the grid component should support any data, such as a set of testimonials with a quote and a customer name. The prepared array is then handed to a render_view function:

<?php // index.php
// Data fetching and formatting same as before

render_view('cards_grid', [
  'cards' => $cards
]);

That function does not exist yet, so it needs to be defined.

The render function

// Defined in functions.php, or somewhere else that will make it globally available.
// If you are worried about possible collisions within the global namespace,
// you can define this function as a static method of a namespaced class
function render_view($view, $data)
{
  extract($data);
  require('views/' . $view . '.php');
}

The function takes a view name and an associative array of data. extract turns each array item into a variable, so a $cards variable holding the prepared items from index.php becomes available. Because the view runs inside its own function, it gets its own scope, which allows short variable names with no risk of collision. The second line prints the view matching the supplied name — in this case views/cards_grid.php.

Templates and subviews

<?php /* views/cards_grid.php */ ?>
<section>
  <ul>
    <?php foreach ($cards as $card) : ?>
    <li>
      <?php render_view('card', $card) ?>
    </li>
    <?php endforeach; ?>    
  </ul>
</section>

The grid template consumes the extracted $cards variable and renders it as an unordered list, delegating each entry to a singular card subview. Keeping a single-card template separate makes it possible to render one card directly or reuse it in another view anywhere in the project.

<?php /* views/card.php */ ?>
<div class="card">
  <?php if (!empty($heading)) : ?>
    <h4><?= htmlspecialchars($heading) ?></h4>      
  <?php endif;
  if (!empty($body)) : ?>
    <p><?= htmlspecialchars($body) ?></p>      
  <?php endif;
  if (!empty($footing)) : ?>      
    <span><?= htmlspecialchars($footing) ?></span>
  <?php endif; ?>
</div>

Since the $card array passed to the render function carried heading, body, and footing keys, variables of those names are available in the template. The data in this example is reasonably assured to be free of XSS hazards, but the view could later receive user input, so running each value through htmlspecialchars is prudent — a script tag in the data will be safely escaped. Checking each variable for a non-empty value before rendering is also useful, since it lets a caller omit a variable without leaving empty tags in the markup.

Takeaway

Templating engines are valuable, but PHP is still well suited to what it was originally designed for: generating dynamic HTML. With some foresight, a basic MVC arrangement that keeps views and controllers separate is achievable in surprisingly little code, and it does not require sacrificing maintainability.