Tempest is still a work in progress. Visit our GitHub or Discord

Controllers

Controllers are the core of any web app, they route an HTTP request through the necessary layers of code to finally return a response.

Routing

In Tempest, a controller action can be any class' method, as long as it's annotated with a Route attribute. Tempest offers some convenient Route attributes out of the box, and you can write your own if you need to.

Out of the box, these Route attributes are available:

  • \Tempest\Http\Route
  • \Tempest\Http\Get
  • \Tempest\Http\Post
  • \Tempest\Http\Delete
  • \Tempest\Http\Put
  • \Tempest\Http\Patch

You can use them like so:

// app/HomeController.php

use Tempest\Http\Get;
use Tempest\View\View;
use function view;

final readonly class HomeController
{
    #[Get(uri: '/home')]
    public function __invoke(): View
    {
        return view('home.view.php');
    }
}

Dynamic routes

You can define dynamic segments in your route URIs by wrapping them in curly braces {}. The segment name inside the braces will be passed as a parameter to your controller method.

Here's an example:

// app/UserController.php

use Tempest\Http\Get;
use Tempest\View\View;
use function Tempest\view;

final readonly class UserController
{
    #[Get(uri: '/users/{id}')]
    public function showUser(int $id): View
    {
        // Fetch the user by ID
        $user = $this->userService->getUserById($id);

        // Pass the user to the view
        return view('user.view.php', ['user' => $user]);
    }
}

Custom regex

It is also possible to add custom regex for dynamic segments.

Here's an example:

// app/UserController.php

use Tempest\Http\Get;
use Tempest\View\View;
use function Tempest\view;

final readonly class UserController
{
    #[Get(uri: '/users/{id:[0-9]+}')]
    public function showUser(int $id): View
    {
        // Fetch the user by ID
        $user = $this->userService->getUserById($id);

        // Pass the user to the view
        return view('user.view.php', ['user' => $user]);
    }
}

Requests

Any web app will soon need to validate and access request data. In Tempest, that data is available via request classes. Every public property on such a request class represents a value that's being sent from the client to the server. Tempest relies on PHP's type system to validate that data, and offers a bunch of validation attributes for more fine-tuned validation.

// app/BookRequest.php

use Tempest\Http\Request;
use Tempest\Http\IsRequest;
use Tempest\Validation\Rules\Length;

final class BookRequest implements Request
{
    use IsRequest;
    
    #[Length(min: 10, max: 120)]
    public string $title;
    
    public ?DateTimeImmutable $publishedAt = null;
    
    public string $summary;
}

Note that this is a pattern you'll see often throughout Tempest: any class that interacts with the framework should implement an interface, and the framework provides a trait with a default implementation, just like Request and IsRequest in this case.

Once you've created your request class, you can add it as an argument to your controller method:

// app/BookController.php

use Tempest\Http\Post;
use Tempest\Http\Responses\Redirect;
use function Tempest\map;
use function Tempest\uri;

final readonly class BookController
{
    #[Post(uri: '/books/create')]
    public function store(BookRequest $request): Redirect
    {
        $book = map($request)->to(Book::class)->save();
        
        return new Redirect(uri([self::class, 'show'], id: $book->id));
    }
}

A full overview of Request objects can be found here.

A note on data mapping

The map() function is another powerful feature that sets Tempest apart. We'll discuss it more in depth when looking at models, but it's already worth mentioning: Tempest can treat any kind of object as "a model", and is able to map data into those objects from different sources.

You could map a request class with its data to a model class, but you could also map a model object to a JSON array; you could map JSON data to models, a model to an array, and so on. The map() function will detect what kind of data source its dealing with and what kind of target that data should be mapped into.

Middleware

Middleware can be applied to handle tasks in between receiving a request and sending a response. Middleware can be applied to routes via the #[Route] attribute:

// app/BookClass.php

use Tempest\Http\Get;
use Tempest\Http\Response;

final readonly class BookClass
{
    #[Get(
        uri: '/books', 
        middleware: [BooksMiddleware::class],
    )]
    public function index(): Response
    {
        // …
    }
}

A middleware class, in turn, should implement the \Tempest\Http\HttpMiddleware interface:

// app/BooksMiddleware.php

use Tempest\Http\HttpMiddleware;
use Tempest\Http\HttpMiddlewareCallable;
use Tempest\Http\Request;
use Tempest\Http\Response;

final readonly class BooksMiddleware implements HttpMiddleware
{
    public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
    {
        $response = $next($request);
        
        $response->addHeader('x-book', 'true');
        
        return $response;
    }
}

Note that you can create custom routes to make reusable middleware stacks.

Responses

Tempest controllers must return one of two objects: a View or a Response. Returning a view is a shorthand for returning a successful response with that view. As a shorthand, you can use the view() function to construct a view.

// app/BookController.php

use Tempest\Http\Get;
use Tempest\View\View;
use function Tempest\view;

final readonly class BookController
{
    #[Get(uri: '/books/{book}')]
    public function show(Book $book, User $user): View
    {
        return view('Front/books/detail.view.php',
            book: $book,
            user: $user,
        );
    }
}

You can read all about views in the next chapter.

If you're returning responses Tempest has a bunch of responses built-in:

  • Created — the 201 response with an optional body
  • Download — downloads a file from the browser
  • File — shows a file in the browser
  • Invalid — a response with form validation errors, redirecting to the previous page
  • NotFound — the 404 response with an optional body
  • Ok — the 200 response with an optional body
  • Redirect — the redirect response
  • ServerError — a 500 server error response

A full overview of responses can be found here.

Returning responses from controllers looks like this:

// app/AdminDownloadController.php

use Tempest\Http\Get;
use Tempest\Http\Responses\Download;
use Tempest\Http\Responses\Redirect;
use Tempest\Http\Response;

final readonly class AdminDownloadController
{  
    #[Get('/admin/download/{path}')]
    public function download(string $path): Response
    {
        $allowed = /* … */;
        
        if (! $allowed) {
            return new Redirect('/');
        }
        
        $sanitizedPath = /* … */;
        
        return new Download($sanitizedPath);
    }
}

Content Types

Tempest will automatically infer the response's content type from the request's Accept header. You can override its content type manually though:

// app/JsonController.php

use Tempest\Http\Get;
use Tempest\Http\ContentType;
use Tempest\Http\Response;
use Tempest\Http\Responses\Ok;

final readonly class JsonController
{  
    #[Get('/json')]
    public function json(string $path): Response
    {
        $data = [ /* … */ ];
    
        return (new Ok($data))->setContentType(ContentType::JSON);
    }
}

Note that you don't have to worry about setting content types if the request has the Accept header specified (wip).

Response Objects

If you want to, you can create your own Response objects for your specific use cases:

// app/BookCreated.php

use Tempest\Http\IsResponse;
use Tempest\Http\Response;
use Tempest\Http\Status;

final class BookCreated implements Response
{
    use IsResponse;

    public function __construct(Book $book) 
    {
        $this->status = Status::CREATED;
        $this->addHeader('x-book-id', $book->id);   
    }
}

Custom Routes

Thanks to route attributes, you can make your own, custom Route implementations. These custom route classes can be used to make route groups that add middleware, do authorization checks, etc.

// app/AdminRoute.php

use \Attribute;
use Tempest\Http\Route;
use Tempest\Http\Method;

#[Attribute]
final readonly class AdminRoute extends Route
{
    public function __construct(string $uri, Method $method)
    {
        parent::__construct(
            uri: $uri,
            method: $method,
            middleware: [
                AdminMiddleware::class,
                LogUserActionsMiddleware::class,
            ]
        );
    }
}

You can now use this AdminRoute attribute for all controller methods that should only be accessed by admins:

// app/BookController

use Tempest\Http\Method;
use Tempest\Http\Response;

final readonly class BookController
{
    // …
    
    #[AdminRoute('/books', Method::POST)]
    public function store(BookRequest $request): Response
    {
        // …
    }
}

Generating URIs

You can generate URIs referencing controller methods by using the \Tempest\uri function:

use function Tempest\uri;

// Invokable classes can be referenced directly:
uri(HomeController::class); 
// /home

// Classes with named methods are referenced using an array
uri([BookController::class, 'store']); 
// /books

// Additional URI parameters are passed in as named arguments:
uri([BookController::class, 'show'], id: $book->id); 
// /books/1

Route Binding

Tempest will map IDs to model instances:

// app/BookController.php

use Tempest\Http\Get;
use http\Env\Response;

final readonly class BookController
{
    #[Get('/books/{book}')]
    public function show(Book $book): Response { /* … */ }
}

Binding to an enum

Tempest can also bind to a string backed enum which causes all enum values to be available as routes.

// app/Genre.php

enum Genre: string
{
    case Thriller = 'thriller';
    case Fantasy = 'fantasy';
    case ScienceFiction = 'science-fiction';
}

// app/BookController.php

use Tempest\Http\Get;
use Tempest\Http\Response;

final readonly class BookController
{
    #[Get('/books/{genre}')]
    public function show(Genre $genre): Response { /* … */ }
}

Deferring tasks

Sometimes you might want to handle some tasks after a response has been sent to the client. For example: you want to send an email but don't want the client to wait until it has been sent. Or, you want to keep track of page visits, but don't want the client having to wait until you update your analytics database.

These cases are well suited for deferred tasks: tasks that are executed after the response already has been sent.

// app/AuthController.php

use Tempest\Http\Post;
use Tempest\Http\Responses\Redirect;
use function Tempest\defer;

final readonly class AuthController
{
    #[Post('/register')]
    public function register(): Redirect
    {  
        $user = // …
        
        defer(function () use ($user) {
            // Send mail to user
        });
        
        return new Redirect('/');
    }
}
// app/PageVisitedMiddleware.php

use Tempest\Http\HttpMiddleware;
use Tempest\Http\Request;
use Tempest\Http\Response;
use function Tempest\defer;

final readonly class PageVisitedMiddleware implements HttpMiddleware
{
    public function __invoke(Request $request, callable $next): Response
    {
        defer(function () use ($request) {
            event(new PageVisited($request->getUri()));
        });

        return $next($request);
    }
}

Note that task deferring only works if fastcgi_finish_request() is available within your PHP installation. If it's not available, deferred tasks will still be run, but the client response will only complete after all tasks have been finished as well.