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.
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.
$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').
(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;
}); 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:
| Method | Purpose | Example |
|---|---|---|
| GET | Read data | View a page, fetch user info |
| POST | Create data | Submit a form, create user |
| PUT | Update data | Edit user profile |
| DELETE | Remove data | Delete 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');
}); $f3->route('GET|POST /contact', ...)This is useful when the same URL handles both displaying a form (GET) and processing it (POST).
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.
<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.
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'); 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.
'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.
beforeRoute() for auth, then extend it:class Admin extends BaseController { ... }Call
parent::beforeRoute(\$f3) in child classes to run the parent logic first.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.
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'];
}); 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
| Problem | Cause | Fix |
|---|---|---|
| 404 on valid route | Route defined after $f3->run() | Define all routes before calling run() |
| Token not captured | Wrong token name in code | Check @token matches $f3->get('PARAMS.token') |
| Route not matching | Order matters | Put specific routes before wildcards |
| Method not allowed | Missing method in mapped class | Add get()/post()/put()/delete() methods |
| Autoloader not working | Namespace mismatch | Check 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.
Quick reference: fatfreeframework.com/quick-reference