Your First Page
Drop a file, get a URL. The filesystem is the router.
You will learn
- Create a page by adding a file to public/
- How implicit routing maps files to URLs
- Add dynamic content with PHP
- Handle query parameters
The problem
In most frameworks, adding a new page means editing a routing table, creating a controller, registering it somewhere, and maybe writing a view. That's a lot of ceremony for "show some HTML."
ZealPHP takes a different approach: the filesystem is the router.
Step 1: Create a file
Create a file at public/greeting.php:
<?php
echo "<h1>Hello, World!</h1>";
echo "<p>This page was served by ZealPHP.</p>";
Step 2: Visit the URL
Open http://localhost:8080/greeting in your browser. That's it — no routing
config, no controller registration. ZealPHP saw a file at public/greeting.php and
mapped it to GET /greeting automatically.
How implicit routing works
The public/ folder is a filing cabinet — folders are drawers, files are documents,
and the URL mirrors the file path. public/blog/post.php → /blog/post.
The .php extension is stripped automatically. No routing table, no config.
That covers static pages with PHP. For URL parameters (/users/{id}), REST APIs,
and the priority order between routing styles, see the How Routes Work
lesson in Foundations.
Adding dynamic content
These are PHP files, so you can use any PHP you want. Let's make the greeting personal. Depending on your Lifecycle Mode, you have two amazing ways to do this:
<?php
// $g gives you the current request's data safely.
$g = \ZealPHP\G::instance();
$name = htmlspecialchars($g->get['name'] ?? 'World');
echo "<h1>Hello, {$name}!</h1>";
<?php
// Classic PHP syntax just works!
$name = htmlspecialchars($_GET['name'] ?? 'World');
echo "<h1>Hello, {$name}!</h1>";
Visit /greeting?name=Alice and the page greets Alice by name.
Which one should I use? For brand new apps, Coroutine Mode (the default) is the fastest and uses $g->get to prevent global state leaks.
However, if you are migrating an older application or simply prefer writing classic PHP syntax, you can flip the switch in your app.php to App::mode(App::MODE_COROUTINE_LEGACY). This leverages ext-zealphp to magically isolate $_GET and other superglobals per-coroutine!
Lesson 4 explains the mental model, and Lesson 26 details the caveats you should be careful about when using the experimental coroutine-legacy mode.
This very site uses implicit routing. The page you're reading right now is served from public/learn.php. Every page in the docs — /routing, /streaming, /ws — is a file in public/.
Try it: Open the greeting demo →
What about messy URLs?
"But I don't want .php in my URLs!" — you won't see them. ZealPHP strips the
extension automatically. public/about.php responds at /about, not
/about.php. Requesting /about.php directly returns 403.
Using a layout
Right now, your page outputs raw HTML with no <head>, no stylesheet, no navigation.
Every real page needs a shared layout. The next nine Foundations lessons cover the request
lifecycle, routing, and the response model that makes layouts work;
Lesson 13 (Layouts & Components) teaches you how to wrap pages
in a layout template using App::render().
Here's a preview — this is how most pages in ZealPHP apps look:
<?php use ZealPHP\App;
App::render('/_master', [
'title' => 'About',
'page' => 'about',
]);
That's the entire file. Three lines. The master template handles the HTML shell, nav, footer, and CSS — your page template only has the content.
If you create a file at public/blog/post.php, what URL will serve it?
Key Takeaways
- Files in
public/automatically become URLs — no routing config needed - The URL mirrors the file path:
public/blog/post.php→/blog/post - Use
$g->getor switch to Coroutine-Legacy mode for classic$_GET— both are safe! .phpextensions are stripped — clean URLs by default