This tutorial builds a small but complete web app, a guestbook that lists
messages and lets visitors post new ones, using three built-in namespaces:
phel.html for the page, phel.http for requests and responses, and
phel.router for dispatch. You will end with a single file you can serve with
php -S.
Every code block here is a self-contained program you can paste into a file and
run with phel run, and each is checked against the runtime on every build.
Sections 1 to 4 are runnable steps that introduce one idea at a time; section 5
assembles them into the final src/guestbook.phel you keep. The trick that makes
a web handler runnable without a server is request-from-map: it builds a request
struct in memory, so you can call your app and inspect the response in plain Phel,
no browser required.
Prerequisites#
Phel installed in a project (see Getting Started).
Run each step below with vendor/bin/phel run <file>.phel to follow along; the
finished file lands in section 5.
1. Hold the state#
The guestbook needs somewhere to keep messages. An atom holds a vector and
swap! updates it. Start there:
(ns guestbook)
(def messages (atom []))
(defn add-message! [name text]
(swap! messages conj {:name name :text text}))
(add-message! "Ada" "First!")
(add-message! "Alan" "Hello from Phel")
(deref messages)
# => [{:name "Ada", :text "First!"} {:name "Alan", :text "Hello from Phel"}]
messages is the whole database for now. Section 5 swaps it for a file.
2. Render the page#
HTML is plain Phel data: a vector is an element, a leading keyword is the tag,
an optional map is attributes (see HTML Rendering).
A function that returns such a vector is a reusable component. Pass the final
tree to html once:
(ns guestbook
(:require phel.html :refer [html doctype]))
(defn entry-view [entry]
[:li [:strong (get entry :name)] ": " (get entry :text)])
(defn page [entries]
(html
(doctype :html5)
[:html
[:head [:title "Guestbook"]]
[:body
[:h1 "Guestbook"]
[:ul (for [m :in entries] (entry-view m))]
[:form {:method "post" :action "/"}
[:input {:type "text" :name "name" :placeholder "Your name"}]
[:input {:type "text" :name "message" :placeholder "Message"}]
[:button "Sign"]]]]))
(php/str_contains (page [{:name "Ada" :text "Hi"}]) "<strong>Ada</strong>")
# => true
html auto-escapes every value, so a message of <script> renders as harmless
text. No template language, just data.
3. Handle a request#
A handler is a one-argument function request -> response. home renders the
page; sign reads the submitted form from :parsed-body, stores it, and
redirects back with a 303. Build requests with request-from-map to call them
directly:
(ns guestbook
(:require phel.http :as http))
(def messages (atom []))
(defn home [request]
(http/response-from-map {:status 200 :body "the page"}))
(defn sign [request]
(let [body (get request :parsed-body)]
(swap! messages conj {:name (get body :name) :text (get body :message)})
(http/response-from-map {:status 303 :headers {"Location" "/"} :body ""})))
(let [req (http/request-from-map
{:method "POST" :uri "/" :parsed-body {:name "Ada" :message "Hi"}})]
[(get (sign req) :status) (deref messages)])
# => [303 [{:name "Ada", :text "Hi"}]]
The handler returns a response struct; sign sets :status 303 and a
Location header so the browser reloads the list after posting.
4. Route requests to handlers#
phel.router maps a [path data] table to handlers, matching both path and
method, so you skip hand-written cond. router/handler turns the table into
one request -> response function, the whole app:
(ns guestbook
(:require phel.http :as http)
(:require phel.router :as router))
(def messages (atom []))
(defn home [request]
(http/response-from-map {:status 200 :body (str "messages: " (count (deref messages)))}))
(defn sign [request]
(let [body (get request :parsed-body)]
(swap! messages conj {:name (get body :name) :text (get body :message)})
(http/response-from-map {:status 303 :headers {"Location" "/"} :body ""})))
(def routes
[["/" {:get {:handler home}
:post {:handler sign}}]])
(def app (router/handler (router/router routes)))
# POST a message, then GET the list, all in memory
(let [post-req (http/request-from-map {:method "POST" :uri "/" :parsed-body {:name "Ada" :message "Hi"}})
get-req (http/request-from-map {:method "GET" :uri "/"})]
(app post-req)
(get-in (app get-req) [:body]))
# => "messages: 1"
app is everything: routing, dispatch, your handlers. A GET /missing would
get a 404 from the router without touching your code. Here home returns a stub
body to keep the focus on routing; the complete app below renders the real page.
5. The complete app#
Assemble the pieces into one src/guestbook.phel. This is the whole app: state,
the page component from section 2, the real home (which now renders
(page ...)), sign, the routes, and app. It is a complete program, copy it
as is:
(ns guestbook
(:require phel.html :refer [html doctype])
(:require phel.http :as http)
(:require phel.router :as router))
(def messages (atom []))
(defn add-message! [name text]
(swap! messages conj {:name name :text text}))
(defn entry-view [entry]
[:li [:strong (get entry :name)] ": " (get entry :text)])
(defn page [entries]
(html
(doctype :html5)
[:html
[:head [:title "Guestbook"]]
[:body
[:h1 "Guestbook"]
[:ul (for [m :in entries] (entry-view m))]
[:form {:method "post" :action "/"}
[:input {:type "text" :name "name" :placeholder "Your name"}]
[:input {:type "text" :name "message" :placeholder "Message"}]
[:button "Sign"]]]]))
(defn home [request]
(http/response-from-map {:status 200 :body (page (deref messages))}))
(defn sign [request]
(let [body (get request :parsed-body)]
(add-message! (get body :name) (get body :message))
(http/response-from-map {:status 303 :headers {"Location" "/"} :body ""})))
(def routes
[["/" {:get {:handler home}
:post {:handler sign}}]])
(def app (router/handler (router/router routes)))
# Quick in-memory check (delete before serving): post a message, render the
# list, confirm the rendered page actually shows it.
(let [post (http/request-from-map {:method "POST" :uri "/" :parsed-body {:name "Ada" :message "Hello"}})
_ (app post)
body (get (app (http/request-from-map {:method "GET" :uri "/"})) :body)]
(php/str_contains body "<strong>Ada</strong>: Hello"))
# => true
The check posts a message and confirms the rendered HTML contains it, proving the whole request to response path before any server is involved.
6. Serve it#
A Phel web app is served through a tiny PHP front controller that boots Phel and runs your namespace. The project layout is three files:
composer.json # requires phel-lang/phel-lang
public/index.php # front controller
src/guestbook.phel # the app from section 5
public/index.php boots Phel and runs the guestbook namespace:
<?php
require __DIR__ . '/../vendor/autoload.php';
\Phel::run(__DIR__ . '/..', 'guestbook');
Then add the entry point at the bottom of src/guestbook.phel: read the request
from PHP's globals, run app, emit the response. Guard it with
(when-not *build-mode* ...) so it only runs when serving, not when the file is
compiled or required by tests:
(when-not *build-mode*
(-> (http/request-from-globals)
(app)
(http/emit-response)))
Install dependencies and start PHP's built-in server with public as the web
root:
composer install
php -S 127.0.0.1:8000 -t public
Open http://127.0.0.1:8000/, sign the guestbook, watch the list grow. (Messages
live in the atom, so they reset when the server restarts: the first item under
Where to go next fixes that.)
Where to go next#
- Persist to disk. Swap the atom for a file: read messages with
phel.jsonon start, write on eachsign. The handler code does not change, onlymessages. - Validate input. Reject empty names before
swap!, return a400with an error message in the page. - Add pages. A second route
["/about" {:get {:handler about}}]and a sharedlayoutcomponent (see HTML Rendering).
Reference: Routing, Request and Response, HTML Rendering.