Tutorial Fat Free Framework

Part 3: Views & Template Engine

Separating logic from presentation with F3 templates

In Part 2, you learned how F3 routes URLs to your code. Your routes probably used echo to output HTML directly — fine for demos, but real apps need proper templates.

This part teaches you how to separate your presentation (HTML) from your logic (PHP). You'll learn F3's built-in template engine, how to pass data from controllers to views, and when to use external engines like Twig.

Prerequisites
You should have F3 working from Part 1 and understand routing from Part 2.

1. The Problem: Spaghetti HTML

Without templates, your route handlers might look like this:

// ❌ Bad: HTML mixed with PHP logic
$f3->route('GET /profile', function($f3) {
    $user = get_user_from_db($f3->get('SESSION.user_id'));
    echo '<html>';
    echo '<head><title>' . $user['name'] . '</title></head>';
    echo '<body>';
    echo '<h1>Welcome, ' . htmlspecialchars($user['name']) . '</h1>';
    echo '<p>Email: ' . htmlspecialchars($user['email']) . '</p>';
    if ($user['is_admin']) {
        echo '<a href="/admin">Admin Panel</a>';
    }
    echo '</body></html>';
});

This works, but it's a nightmare to maintain. Want to change the layout? Edit PHP. Want to adjust the HTML? Edit PHP. Designer on your team? They can't touch this.

The solution: separate your logic (PHP) from your presentation (HTML).

2. PHP as a Template Engine

F3 supports plain PHP templates. First, create a views/ folder in your project root, then create views/profile.htm:

<!DOCTYPE html>
<html>
<head>
    <title><?= $title ?></title>
</head>
<body>
    <h1>Welcome, <?= htmlspecialchars($name) ?></h1>
    <p>Email: <?= htmlspecialchars($email) ?></p>
</body>
</html>

Then render it from your route:

$f3->route('GET /profile', function($f3) {
    // In a real app, fetch from database
    $user = ['name' => 'Dimas', 'email' => '[email protected]'];

    $f3->set('title', 'My Profile');
    $f3->set('name', $user['name']);
    $f3->set('email', $user['email']);

    echo \View::instance()->render('views/profile.htm');
});
Under the Hood: View vs Template
View = PHP template. Uses <?= ?> tags. You have full PHP power but risk mixing logic into your template.

Template = F3 template engine. Uses {{ @var }} syntax. Escapes by default, no PHP allowed. Forces separation.

Both render the same result. Choose based on your team's discipline.

3. F3 Template Syntax

F3 has its own template engine that uses {{ @variable }} syntax instead of PHP. Create the same profile as an F3 template:

<!DOCTYPE html>
<html>
<head>
    <title>{{ @title }}</title>
</head>
<body>
    <h1>Welcome, {{ @name }}</h1>
    <p>Email: {{ @email }}</p>
</body>
</html>

The @ prefix tells F3 to look up the variable in its internal storage. Render it with the Template class:

$f3->route('GET /profile', function($f3) {
    // In a real app, fetch from database
    $user = ['name' => 'Dimas', 'email' => '[email protected]'];

    $f3->set('title', 'My Profile');
    $f3->set('name', $user['name']);
    $f3->set('email', $user['email']);

    echo \Template::instance()->render('views/profile.htm');
});
Performance Note
F3 templates are compiled to PHP on first render. Subsequent requests use the cached PHP. Performance is identical to raw PHP templates — no overhead.

4. Variables & Expressions

Basic Variables

// Set variables
$f3->set('name', 'Dimas');
$f3->set('title', 'My Blog');
<h1>{{ @title }}</h1>
<p>Hello, {{ @name }}!</p>

Arrays and Dot Notation

Dot notation accesses array keys:

$f3->set('user.name', 'Dimas');      // $user['name']
$f3->set('user.email', '[email protected]');  // $user['email']
<p>Name: {{ @user.name }}</p>
<p>Email: {{ @user.email }}</p>
Dot Notation Gotcha
@foo.bar = $foo['bar'] (array access)

@foo.@bar = $foo . $bar (string concatenation)

Easy to mix up. Be careful.

Expressions

You can embed expressions inside templates:

<!-- Arithmetic -->
<p>Page {{ @page }} of {{ @total_pages }}</p>
<p>Item {{ (@page - 1) * @per_page + 1 }} - {{ @page * @per_page }}</p>

<!-- Ternary -->
<option {{ @active ? 'selected' : '' }}>Active</option>

<!-- Function calls -->
<p>Uppercase: {{ strtoupper(@name) }}</p>

<!-- Array items -->
<p>First item: {{ @items[0] }}</p>

5. Conditionals

Use the <check> directive for if/else logic in templates:

Basic If/Else

<check if="{{ @loggedin }}">
    <true>
        <p>Welcome back, {{ @user.name }}!</p>
        <a href="/logout">Logout</a>
    </true>
    <false>
        <p>Please <a href="/login">log in</a>.</p>
    </false>
</check>

Simple If (No False Block)

<!-- If loggedin is truthy, show this -->
<check if="{{ @loggedin }}">
    <p>You are logged in.</p>
</check>

Complex Conditions

<check if="{{ @user.role == 'admin' && @user.active }}">
    <true><a href="/admin">Admin Panel</a></true>
    <false><!-- no admin link --></false>
</check>
Truthy Evaluation
A <check> evaluates to <false> if the value is: NULL, empty string, FALSE, empty array, or 0. This is standard PHP truthiness.

6. Loops

Use the <repeat> directive to iterate over arrays:

Basic Loop

$f3->set('fruits', ['Apple', 'Banana', 'Orange']);
<ul>
<repeat group="{{ @fruits }}" value="{{ @fruit }}">
    <li>{{ @fruit }}</li>
</repeat>
</ul>

Output:

<ul>
    <li>Apple</li>
    <li>Banana</li>
    <li>Orange</li>
</ul>

Loop with Key

$f3->set('user', ['name' => 'Dimas', 'role' => 'admin']);
<dl>
<repeat group="{{ @user }}" key="{{ @key }}" value="{{ @val }}">
    <dt>{{ @key }}</dt>
    <dd>{{ @val }}</dd>
</repeat>
</dl>

Loop with Counter

<repeat group="{{ @items }}" value="{{ @item }}" counter="{{ @ctr }}">
    <p class="{{ @ctr % 2 ? 'odd' : 'even' }}">{{ @item }}</p>
</repeat>

Nested Loops

$f3->set('menu', [
    'Fruits' => ['Apple', 'Banana'],
    'Vegetables' => ['Carrot', 'Broccoli']
]);
<repeat group="{{ @menu }}" key="{{ @category }}" value="{{ @items }}">
    <h3>{{ @category }}</h3>
    <ul>
        <repeat group="{{ @items }}" value="{{ @item }}">
            <li>{{ @item }}</li>
        </repeat>
    </ul>
</repeat>
Performance Note
For very large datasets (10k+ rows), consider paginating in your controller before passing to the template. <repeat> compiles to PHP foreach — it's fast, but rendering 10,000 DOM elements is slow regardless of template engine.

7. Template Includes

Real apps have shared headers, footers, and sidebars. F3 uses <include> to embed other templates.

Basic Include

<!-- layout.htm -->
<include href="views/header.htm" />

<main>
    {{ @content | raw }}
</main>

<include href="views/footer.htm" />

Dynamic Includes

You can include templates based on variables:

// Switch content based on route
$f3->set('content', 'views/blog.htm');
// In another route:
$f3->set('content', 'views/wiki.htm');
<include href="{{ @content }}" />

Include with Parameters

<!-- Pass variables to sub-template -->
<include href="views/sidebar.htm" with="title='Menu',show_icons=true" />

Conditional Include

<!-- Only include if user is logged in -->
<include if="{{ @loggedin }}" href="views/user-menu.htm" />
Layout Pattern
A common pattern is a main layout.htm that includes shared elements and a content variable:

<include href="header.htm" />
{{ @content | raw }}
<include href="footer.htm" />

Each route sets $f3->set('content', '...') to control what appears in the middle.

8. Passing Data to Views

The pattern is always the same: set variables in your controller, consume them in your template.

$f3->route('GET /dashboard', function($f3) {
    // Fetch data (replace with real DB queries)
    $user = ['name' => 'Dimas', 'role' => 'admin'];
    $stats = ['post_count' => 42, 'views_today' => 156];
    $notifications = [
        ['message' => 'New comment on your post'],
        ['message' => 'Welcome back!']
    ];

    // Set template variables
    $f3->set('page_title', 'Dashboard');
    $f3->set('user', $user);
    $f3->set('stats', $stats);
    $f3->set('notifications', $notifications);
    $f3->set('content', 'views/dashboard.htm');

    // Render layout with content
    echo \Template::instance()->render('views/layout.htm');
});
<!-- views/dashboard.htm -->
<h1>Welcome, {{ @user.name }}</h1>

<div class="stats">
    <p>Total posts: {{ @stats.post_count }}</p>
    <p>Views today: {{ @stats.views_today }}</p>
</div>

<check if="{{ count(@notifications) > 0 }}">
    <true>
        <h2>Notifications</h2>
        <ul>
        <repeat group="{{ @notifications }}" value="{{ @note }}">
            <li>{{ @note.message }}</li>
        </repeat>
        </ul>
    </true>
    <false>
        <p>No new notifications.</p>
    </false>
</check>

9. Escaping & Sanitization

F3's template engine (using Template class) automatically escapes all variables to prevent XSS attacks. This means < becomes &lt; and is displayed as literal text, not HTML.

Note: PHP templates (using View class) do NOT auto-escape. You must use htmlspecialchars() manually on every output.

Auto-Escaping (Default)

$f3->set('user_input', '<script>alert("XSS")</script>');
<!-- Safe: rendered as text, script won't execute -->
<p>{{ @user_input }}</p>

<!-- Output: &lt;script&gt;alert("XSS")&lt;/script&gt; -->

Raw Output (Use Carefully)

When you need to render trusted HTML (like content from a WYSIWYG editor), use the raw filter:

<!-- Render HTML as-is (no escaping) -->
<div>{{ @article.html_content | raw }}</div>
Security Warning
Only use | raw for content you trust. Never use it on user input unless you've sanitized it first. A malicious script in | raw content can steal cookies, redirect users, or worse.

Sanitizing User Input

Use $f3->scrub() to clean input before storing or displaying:

// Remove all HTML tags
$clean = $f3->scrub($user_input);

// Allow specific tags
$clean = $f3->scrub($user_input, 'p; br; strong; em');

// Allow all tags but remove unsafe characters
$clean = $f3->scrub($user_input, '*');

10. External Template Engines

F3 works with any template engine. If you prefer Twig or Smarty, you can use them instead.

Comparison

FeatureF3 TemplatesTwigSmarty
Variable syntax{{ @var }}{{ var }}{$var}
Template inheritanceInclude onlyFull block inheritanceFull block inheritance
DependenciesNonetwig/twig packagesmarty/smarty package
Learning curveEasyModerateSteep
Auto-escapingYesYesYes
Best forF3-only projectsComplex layoutsLegacy projects

When to Use Each

  • F3 Templates — Simple projects, minimal dependencies, when you want to stay within F3's ecosystem.
  • Twig — Complex layouts with deep inheritance, teams familiar with Symfony (which uses Twig), when you need features like macros and custom tags.
  • Smarty — Legacy projects already using Smarty, teams with designers who prefer Smarty syntax.
Senior Engineers: Choosing Wisely
For most F3 projects, the built-in template engine is sufficient. It compiles to PHP, has auto-escaping, and no external dependencies.

Consider Twig if: (1) your team already knows it from Symfony, (2) you need complex template inheritance, or (3) you want custom tags and macros.

Don't add Twig just because it's "more popular" — F3 templates are faster to set up and have zero dependencies.

Putting It Together

Here's a complete example that ties together routing (Part 2) with templates:

my-app/
├── index.php
├── views/
│   ├── layout.htm      # Main layout with header/footer
│   ├── home.htm        # Home page content
│   ├── blog.htm        # Blog post list
│   └── post.htm        # Single post view
└── assets/
    └── style.css
<?php
require 'vendor/autoload.php';
$f3 = \Base::instance();

// Sample data (replace with database in real app)
$posts = [
    ['slug' => 'hello-world', 'title' => 'Hello World', 'body' => 'My first post.'],
    ['slug' => 'second-post', 'title' => 'Second Post', 'body' => 'Another post.'],
];

// Home page
$f3->route('GET /', function($f3) {
    $f3->set('page_title', 'Home');
    $f3->set('content', 'views/home.htm');
    echo \Template::instance()->render('views/layout.htm');
});

// Blog listing
$f3->route('GET /blog', function($f3) use ($posts) {
    $f3->set('page_title', 'Blog');
    $f3->set('posts', $posts);
    $f3->set('content', 'views/blog.htm');
    echo \Template::instance()->render('views/layout.htm');
});

// Single post
$f3->route('GET /blog/@slug', function($f3) use ($posts) {
    $slug = $f3->get('PARAMS.slug');
    $post = null;
    foreach ($posts as $p) {
        if ($p['slug'] === $slug) {
            $post = $p;
            break;
        }
    }

    if (!$post) {
        $f3->error(404);
        return;
    }

    $f3->set('page_title', $post['title']);
    $f3->set('post', $post);
    $f3->set('content', 'views/post.htm');
    echo \Template::instance()->render('views/layout.htm');
});

$f3->run();
<!-- views/layout.htm -->
<!DOCTYPE html>
<html>
<head>
    <title>{{ @page_title }} - My Blog</title>
    <link href="{{ @BASE }}/assets/style.css" rel="stylesheet">
</head>
<body>
    <header>
        <nav>
            <a href="/">Home</a>
            <a href="/blog">Blog</a>
        </nav>
    </header>
    <main>
        <include href="{{ @content }}" />
    </main>
    <footer>
        <p>&copy; 2026 My Blog</p>
    </footer>
</body>
</html>

Troubleshooting

ProblemCauseFix
Undefined variable errorVariable not set before renderSet all variables with $f3->set() before calling render()
Raw output when escapedUsing | raw filterRemove | raw — auto-escaping is working correctly
Template not foundWrong path or filenamePaths are relative to index.php; check file extension (.htm)
Array to string errorPrinting array directlyAccess array items: @items[0] or loop with <repeat>
Include not workingWrong href pathUse absolute path from root: /views/header.htm

What's Next?

You now know how to build pages with F3's template engine. In Part 4: Database & ORM, you'll learn:

  • Connecting to MySQL and SQLite databases
  • The SQL Mapper (Active Record pattern)
  • CRUD operations without writing SQL
  • Relationships: has-one and has-many
  • When to drop down to raw SQL

A blog without a database is just static files. Let's add persistence in Part 4.