Tutorial Fat Free Framework

Part 2: Routing & Controllers

How F3 maps URLs to code — and how to organize it

In Part 1, you built a Hello World app with a single route. One route is fine for a demo, but real apps need many pages — a home page, about page, contact form, user profiles, APIs.

This part teaches you how F3 routes work: how to define multiple routes, capture URL parameters, handle different HTTP methods, and organize your code into controllers.

Prerequisites
You should have F3 installed and working from Part 1. All examples assume you're using Composer with require 'vendor/autoload.php';

1. Basic Routing

A route is a pattern that matches a URL. When a request matches, F3 runs your code. Here's a simple app with two routes:

<?php
require 'vendor/autoload.php';
$f3 = \Base::instance();

// Home page
$f3->route('GET /', function() {
    echo 'Welcome to the home page!';
});

// About page
$f3->route('GET /about', function() {
    echo 'About us.';
});

$f3->run();

Visit / and you get the home page. Visit /about and you get the about page. F3 matches the request to the first route that fits.

Under the Hood: Route Matching
F3 stores routes in an internal array in the order you define them. When $f3->run() executes, it iterates through this array and returns the first match. This means route order matters — put specific routes before wildcard routes. For example, define GET /about before GET /@page, otherwise /about might be captured by the wildcard.

2. Route Parameters (Tokens)

Static routes like /about are simple. But what about dynamic URLs like /user/42 or /blog/my-first-post? F3 uses tokens — placeholders that capture parts of the URL.

Basic Token Syntax

Use @name to define a token in your route pattern:

// Match /user/123, /user/456, etc.
$f3->route('GET /user/@id', function($f3) {
    $id = $f3->get('PARAMS.id');
    echo 'User ID: ' . $id;
});

The token @id captures whatever comes after /user/. F3 stores it in the PARAMS array, which you access via $f3->get('PARAMS.id').

Token Values Are Always Strings
F3 captures URL tokens as strings. If you need an integer, cast it: (int) $f3->get('PARAMS.id'). No built-in type validation — that's your responsibility.

Multiple Tokens

You can have multiple tokens in a single route:

// Match /product/electronics/laptop
$f3->route('GET /product/@category/@item', function($f3) {
    $category = $f3->get('PARAMS.category');
    $item = $f3->get('PARAMS.item');
    echo "Category: {$category}, Item: {$item}";
});

The Wildcard (*)

Use * to match anything after a path — useful for catch-all routes or file paths:

// Match /files/anything/here/also
$f3->route('GET /files/*', function($f3) {
    $path = $f3->get('PARAMS.1'); // captured wildcard path
    echo 'Requested file path: ' . $path;
});
Avoid Mixing Tokens and Wildcards
Don't define both GET /brew/@count and GET /brew/* in the same app. F3 will get confused about which route to match. Pick one pattern and stick with it.

3. HTTP Methods

Real apps don't just read data — they create, update, and delete it. HTTP has methods for this:

MethodPurposeExample
GETRead dataView a page, fetch user info
POSTCreate dataSubmit a form, create user
PUTUpdate dataEdit user profile
DELETERemove dataDelete a user

In F3, you define a route for each method:

// Read all items
$f3->route('GET /items', function() {
    echo 'List of items';
});

// Create a new item
$f3->route('POST /items', function($f3) {
    $name = $f3->get('POST.name');
    echo 'Created: ' . $name;
});

// Update an item
$f3->route('PUT /items/@id', function($f3) {
    echo 'Updating item ' . $f3->get('PARAMS.id');
});

// Delete an item
$f3->route('DELETE /items/@id', function($f3) {
    echo 'Deleting item ' . $f3->get('PARAMS.id');
});
Multiple Methods on One Route
You can handle multiple methods with the pipe separator:

$f3->route('GET|POST /contact', ...)

This is useful when the same URL handles both displaying a form (GET) and processing it (POST).
PUT and DELETE from HTML Forms
HTML forms only support GET and POST natively. To send PUT or DELETE requests:

Option A — AJAX: Use fetch() or XMLHttpRequest with the correct method.

Option B — Method spoofing: Add a hidden input <input type="hidden" name="_method" value="PUT"> and configure F3 to read it. This is common in many PHP frameworks.

4. Named Routes

Hardcoding URLs like /user/42 throughout your code creates a maintenance problem — change the URL structure and you break every link. Named routes solve this.

// Define a named route
$f3->route('GET @user_profile: /user/@id', 'User->profile');

// Redirect to a named route
$f3->reroute('@user_profile');

// Redirect with token values
$f3->reroute('@user_profile(id=42)');

Now if you change /user/@id to /profile/@id, you only update it in one place. All reroute() calls still work.

Named Routes in Templates
In F3 templates (covered in Part 3), you can generate URLs from named routes:

<a href="{{ 'user_profile', 'id=42' | alias }}">View Profile</a>

The alias filter builds the full URL from the route name and token values.

5. ReST Mapping with $f3->map()

If you're building a REST API, defining four separate routes for CRUD operations is tedious. F3's map() method automates this by mapping HTTP methods to class methods.

class Item {
    function get($f3) {
        echo 'Read item ' . $f3->get('PARAMS.id');
    }
    function post($f3) {
        echo 'Create item';
    }
    function put($f3) {
        echo 'Update item ' . $f3->get('PARAMS.id');
    }
    function delete($f3) {
        echo 'Delete item ' . $f3->get('PARAMS.id');
    }
}

$f3->map('/items/@id', 'Item');
$f3->run();

Now GET /items/42 calls Item->get(), POST /items/42 calls Item->post(), and so on.

map() Caveats
405 Method Not Allowed: If a request uses a method not defined in your class (e.g., PATCH when you only have get/post/put/delete), F3 returns HTTP 405.

Method spoofing: F3 supports <input name="_method" value="PUT"> for HTML forms that don't support PUT/DELETE natively.

Options handling: F3 automatically responds to OPTIONS requests with allowed methods — useful for CORS preflight.

5b. Route Groups & Shared Prefixes

When you have many routes that share a URL prefix (like /api/v1/users, /api/v1/items), repeating the prefix is tedious. F3 offers two approaches:

Option 1: PREMAP Variable

Set PREMAP before defining routes, and F3 prepends it automatically:

// All routes below get /api/v1 prefix
$f3->set('PREMAP', '/api/v1');

$f3->route('GET /users', 'Api\\User->list');       // /api/v1/users
$f3->route('GET /users/@id', 'Api\\User->get');   // /api/v1/users/42
$f3->route('POST /users', 'Api\\User->create');   // /api/v1/users

// Reset when done
$f3->set('PREMAP', '');

Option 2: Manual Prefixing

For more control, just define the full path in each route:

$f3->route('GET /api/v1/users', 'Api\\User->list');
$f3->route('GET /api/v1/users/@id', 'Api\\User->get');
$f3->route('GET /api/v1/items', 'Api\\Item->list');
When to Use Each
PREMAP is cleaner for API versioning or admin panels with many routes. Just set it once at the top.

Manual prefixing is more explicit and easier to debug. Use it when routes have different prefixes or when you need clarity in large codebases.

6. Closures vs Controller Classes

You can route requests to closures (anonymous functions) or to controller classes. Both work — the choice depends on your project's size.

Option A: Closures (Quick & Simple)

$f3->route('GET /about', function() {
    echo 'About us';
});

$f3->route('GET /contact', function($f3) {
    $f3->set('title', 'Contact Us');
    echo render_template('contact.php', $f3->get('title')); // hypothetical
});

Good for: small apps, prototypes, single-file projects. Everything is visible in one place.

Option B: Controller Classes (Organized & Reusable)

// In index.php
$f3->route('GET /about', 'Page->about');
$f3->route('GET /contact', 'Page->contact');

// In classes/Page.php
class Page {
    function about() {
        echo 'About us';
    }
    function contact() {
        echo 'Contact form here';
    }
}

Good for: apps with many routes, teams, anything you want to test. Classes can be autoloaded, inherited, and tested in isolation.

Under the Hood: String-Based Routing
When you pass a string like 'Page->about' to $f3->route(), F3 parses it at runtime:

1. Split on -> (object) or :: (static)
2. Check if the class is loaded (autoloader)
3. Instantiate if needed, then call the method

This is slower than direct method calls due to runtime reflection, but negligible for most apps. If performance is critical, use $f3->set('AUTOLOAD', 'classes/') to enable F3's built-in autoloader.

7. Before/After Route Filters

Controller classes can have beforeRoute() and afterRoute() methods that run before and after every route handler in that class.

class Admin {
    function beforeRoute($f3) {
        // Check if user is logged in
        if (!$f3->get('SESSION.user')) {
            $f3->reroute('/login');
        }
    }

    function dashboard() {
        echo 'Welcome to the admin dashboard';
    }

    function settings() {
        echo 'Admin settings';
    }
}

$f3->route('GET /admin', 'Admin->dashboard');
$f3->route('GET /admin/settings', 'Admin->settings');

Both /admin and /admin/settings trigger beforeRoute() first. If the user isn't logged in, they get redirected to /login before the handler runs.

Inheritance Tip
Create a base controller with beforeRoute() for auth, then extend it:

class Admin extends BaseController { ... }

Call parent::beforeRoute(\$f3) in child classes to run the parent logic first.
Limitation: No Global Before Filter
Unlike Laravel's middleware, F3 doesn't have a built-in "run this on every route" mechanism. Workarounds:

Option 1: Use a base controller and extend it for all your controllers.

Option 2: Set $f3->set('BEFORE', function(\$f3) { ... }) — this runs before every route, but doesn't have access to the target class.

Option 3: Use $f3->set('ONREROUTE', ...) for reroute-specific logic.

8. Dynamic Route Handlers

F3 allows you to use tokens in the route handler string, not just the URL pattern:

// /products/list -> Products->list()
// /products/detail -> Products->detail()
// /products/delete -> Products->delete()
$f3->route('GET /products/@action', 'Products->@action');

The @action token in the handler is replaced with the value from the URL. This is useful for CRUD controllers where many methods share a URL prefix.

Security Warning
Dynamic handlers can call any public method on your class. If your controller has a method you don't want exposed (like internalHelper()), it could be called via /products/internalHelper.

Fix: Use _ prefix for private methods (convention) or check method names in beforeRoute().

9. AJAX vs Sync Modifiers

Sometimes you want different behavior for AJAX requests vs regular page loads on the same URL. F3 lets you specify this with modifiers:

// Regular browser request -> full HTML page
$f3->route('GET /profile [sync]', function() {
    echo '<html><body><h1>My Profile</h1>...</body></html>';
});

// AJAX request -> just the content fragment
$f3->route('GET /profile [ajax]', function() {
    echo '<h1>My Profile</h1><p>Just the content, no layout</p>';
});

F3 checks for the X-Requested-With: XMLHttpRequest header to detect AJAX. If no modifier is specified, the route handles both types.

10. Error Handling

F3 automatically returns a 404 page when no route matches. But sometimes you need to trigger a 404 manually — for example, when a database record isn't found.

$f3->route('GET /user/@id', function($f3) {
    $id = (int) $f3->get('PARAMS.id');
    $user = find_user_in_database($id); // your function

    if (!$user) {
        $f3->error(404); // trigger 404
        return;
    }

    echo 'User: ' . $user['name'];
});
Custom Error Pages
To override the default 404 page, set the ONERROR handler:

$f3->set('ONERROR', function($f3) {
  if ($f3->get('ERROR.code') == 404) {
    echo 'Custom 404 page';
  }
});

Putting It Together

Here's a realistic example that combines multiple routing concepts:

<?php
require 'vendor/autoload.php';
$f3 = \Base::instance();

// Set up autoloader for controller classes
$f3->set('AUTOLOAD', 'classes/');

// Named routes
$f3->route('GET @home: /', function() {
    echo '<h1>Welcome</h1>';
});

$f3->route('GET @about: /about', function() {
    echo '<h1>About Us</h1>';
});

// User routes with parameters
$f3->route('GET /user/@id', function($f3) {
    echo 'Profile for user ' . $f3->get('PARAMS.id');
});

// RESTful API using map()
$f3->map('/api/items/@id', 'Api\\Item');

// Dynamic handler for product pages
$f3->route('GET /products/@action', 'Product->@action');

// 404 handler
$f3->set('ONERROR', function($f3) {
    if ($f3->get('ERROR.code') == 404) {
        echo '<h1>404 - Page Not Found</h1>';
    }
});

$f3->run();

Troubleshooting

ProblemCauseFix
404 on valid routeRoute defined after $f3->run()Define all routes before calling run()
Token not capturedWrong token name in codeCheck @token matches $f3->get('PARAMS.token')
Route not matchingOrder mattersPut specific routes before wildcards
Method not allowedMissing method in mapped classAdd get()/post()/put()/delete() methods
Autoloader not workingNamespace mismatchCheck folder structure matches namespace

What's Next?

You now understand how F3 routes requests to your code. In Part 3: Views & Template Engine, you'll learn:

  • F3's template syntax — variables, expressions, escaping
  • Template includes and shared layouts
  • Loops and conditionals in templates
  • Passing data from controllers to views
  • Using external template engines (Twig, Smarty) if you prefer

Separating logic from presentation is the foundation of maintainable code. Let's build that separation in Part 3.

Resources