diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 000000000..15597905a --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,20 @@ +name: Deploy documentation +on: + push: + branches: + - main + - 2.x + +jobs: + trigger: + runs-on: ubuntu-latest + steps: + - name: Trigger documentation deployment + uses: octokit/request-action@v2.0.0 + with: + route: POST /repos/:owner/:repo/dispatches + owner: tempestphp + repo: tempest-docs + event_type: deploy + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/bun.lock b/bun.lock index bdbf8e667..d8ccee93d 100644 --- a/bun.lock +++ b/bun.lock @@ -3,18 +3,18 @@ "workspaces": { "": { "devDependencies": { - "@types/bun": "1.2.19", - "bumpp": "10.2.1", - "dprint": "0.50.1", - "typescript": "5.8.3", + "@types/bun": "latest", + "bumpp": "^10.0.1", + "dprint": "^0.50.0", + "typescript": "^5.7.3", "unbuild": "^3.3.1", "vite-plugin-tempest": "workspace:*", - "vitest": "3.2.4", + "vitest": "^3.2.3", }, }, "packages/vite-plugin-tempest": { "name": "vite-plugin-tempest", - "version": "1.5.0", + "version": "1.6.0", "dependencies": { "@innocenzi/utils": "^0.3.0", "picocolors": "^1.1.1", @@ -192,7 +192,7 @@ "@trysound/sax": ["@trysound/sax@0.2.0", "", {}, "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="], - "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], + "@types/bun": ["@types/bun@1.2.20", "", { "dependencies": { "bun-types": "1.2.20" } }, "sha512-dX3RGzQ8+KgmMw7CsW4xT5ITBSCrSbfHc36SNT31EOUg/LA9JWq0VDdEXDRSe1InVWpd2yLUM1FUF/kEOyTzYA=="], "@types/chai": ["@types/chai@5.2.2", "", { "dependencies": { "@types/deep-eql": "*" } }, "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg=="], @@ -236,7 +236,7 @@ "bumpp": ["bumpp@10.2.1", "", { "dependencies": { "ansis": "^4.1.0", "args-tokenizer": "^0.3.0", "c12": "^3.1.0", "cac": "^6.7.14", "escalade": "^3.2.0", "jsonc-parser": "^3.3.1", "package-manager-detector": "^1.3.0", "semver": "^7.7.2", "tinyexec": "^1.0.1", "tinyglobby": "^0.2.14", "yaml": "^2.8.0" }, "bin": { "bumpp": "bin/bumpp.mjs" } }, "sha512-Dhgao1WhrcMg+1R3GU+57e6grUNNIGORN53YllDFurNEVGWmkD/z63R3xX4Sl9IqEw//1/UxbrvmK8V1pcJDHw=="], - "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], + "bun-types": ["bun-types@1.2.20", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-pxTnQYOrKvdOwyiyd/7sMt9yFOenN004Y6O4lCcCUoKVej48FS5cvTw9geRaEcB9TsDZaJKAxPTVvi8tFsVuXA=="], "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], diff --git a/docs/0-getting-started/01-introduction.md b/docs/0-getting-started/01-introduction.md new file mode 100644 index 000000000..804a7f50d --- /dev/null +++ b/docs/0-getting-started/01-introduction.md @@ -0,0 +1,193 @@ +--- +title: Introduction +description: "Tempest is a framework for PHP development, designed to get out of your way. Its core philosophy is to help you focus on your application code, without being bothered hand-holding the framework." +--- + +Tempest's goal is to make you more productive when building web and console apps in PHP. It handles all the boilerplate parts of such projects for you, so that you can focus on what matters the most: writing application code. + +People using Tempest say it's the sweet spot between the robustness of Symfony and the eloquence of Laravel. It feels lightweight and close to vanilla PHP; and yet powerful and feature-rich. On this page, you'll read what sets Tempest apart as a framework for modern PHP development. If you're already convinced, you can head over to the [installation page](../0-getting-started/02-installation.md) and get started with Tempest. + +## Vision + +Tempest's vision can be summarized like this: **it's a community-driven, modern PHP framework that gets out of your way and dares to think outside the box**. Let's dissect that vision in depth. + +### Community driven + +Tempest started out as an educational project, without the intention for it to be something real. People picked up on it, though, and it was only after a strong community had formed that we considered making it something real. + +Currently, there are three core members dedicating a lot of their time to Tempest, as well as over [50 additional contributors](https://github.com/tempestphp/tempest-framework). We have an active [Discord server](/discord) with close to 400 members. + +Tempest isn't a solo project and never has been. It is a new framework and has a way to go compared to Symfony or Laravel, but there already is significant momentum and will only keep growing. + +### Embracing modern PHP + +The benefit of starting from scratch like Tempest did is having a clean slate. Tempest embraced modern PHP features from the start, and its goal is to keep doing this in the future by shipping built-in upgraders whenever breaking changes happen (think of it as Laravel Shift, but built into the framework). + +Just to name a couple of examples, Tempest uses property hooks: + +```php +interface DatabaseMigration +{ + public string $name { + get; + } + + public function up(): ?QueryStatement; + + public function down(): ?QueryStatement; +} +``` + +Attributes: + +```php +final class BookController +{ + #[Get('/books/{book}')] + public function show(Book $book): Response { /* … */ } +} +``` + +Proxy objects: + +```php +use Tempest\Container\Proxy; + +final readonly class BookController +{ + public function __construct( + #[Proxy] private SlowDependency $slowDependency, + ) { /* … */ } +} +``` + +And a lot more. + +### Getting out of your way + +A core part of Tempest's philosophy is that it wants to "get out of your way" as best as possible. For starters, Tempest is designed to structure your project code however you want, without making any assumptions or forcing conventions on you. You can prefer a classic MVC application, DDD or hexagonal design, microservices, or something else; Tempest works with any project structure out of the box without any configuration. + +Behind Tempest's flexibility is one of its most powerful features: [discovery](../internals/discovery). Discovery gives Tempest a great number of insights into your codebase, without any handholding. Discovery handles routing, console commands, view components, event listeners, command handlers, middleware, schedules, migrations, and more. + +```php +final class ConsoleCommandDiscovery implements Discovery +{ + use IsDiscovery; + + public function __construct( + private readonly ConsoleConfig $consoleConfig, + ) {} + + public function discover(DiscoveryLocation $location, ClassReflector $class): void + { + foreach ($class->getPublicMethods() as $method) { + if ($consoleCommand = $method->getAttribute(ConsoleCommand::class)) { + $this->discoveryItems->add($location, [$method, $consoleCommand]); + } + } + } + + public function apply(): void + { + foreach ($this->discoveryItems as [$method, $consoleCommand]) { + $this->consoleConfig->addCommand($method, $consoleCommand); + } + } +} +``` + +Discovery makes Tempest truly understand your codebase so that you don't have to explain the framework how to use it. Of course, discovery is heavily optimized for local development and entirely cached in production, so there's no performance overhead. Even better: discovery isn't just a core framework feature, you're encouraged to write your own project-specific discovery classes wherever they make sense. That's the Tempest way. + +Besides Discovery, Tempest is designed to be extensible. You'll find that any part of the framework can be replaced and hooked into by implementing an interface and plugging it into the container. No fighting the framework, Tempest gets out of your way. + +```php +use Tempest\View\ViewRenderer; + +$container->singleton(ViewRenderer::class, $myCustomViewRenderer); +``` + +### Thinking outside the box + +Finally, since Tempest originated as an educational project, many Tempest features dare to rethink the things we've gotten used to. For example, [console commands](../1-essentials/04-console-commands), which in Tempest are designed to be very similar to controller actions: + +```php +final readonly class BooksCommand +{ + use HasConsole; + + public function __construct( + private BookRepository $repository, + ) {} + + #[ConsoleCommand] + public function find(?string $initial = null): void + { + $book = $this->search( + 'Find your book', + $this->repository->find(...), + ); + } + + #[ConsoleCommand(middleware: [CautionMiddleware::class])] + public function delete(string $title, bool $verbose = false): void + { /* … */ } +} +``` + +Or what about [Tempest's ORM](../1-essentials/03-database), which aims to have truly decoupled models: + +```php +use Tempest\Validation\Rules\Length; +use App\Author; + +final class Book +{ + #[Length(min: 1, max: 120)] + public string $title; + + public ?Author $author = null; + + /** @var \App\Chapter[] */ + public array $chapters = []; +} +``` + +```php +final class BookRepository +{ + public function findById(int $id): Book + { + return query(Book::class) + ->select() + ->with('chapters', 'author') + ->where('id = ?', $id) + ->first(); + } +} +``` + +Then there's our view engine, which embraces the most original template engine of all time: HTML; + +```html + + + +``` + +## Getting started + +Are you intrigued? Want to give Tempest a try? Head over to [the next chapter](../0-getting-started/02-installation.md) to learn about how to get started with Tempest. + +If you want to become part of our community, you're more than welcome to [join our Discord server](/discord), and to check out [Tempest on GitHub](https://github.com/tempestphp/tempest-framework). + +Enjoy! diff --git a/docs/0-getting-started/02-installation.md b/docs/0-getting-started/02-installation.md new file mode 100644 index 000000000..dfd4bf11d --- /dev/null +++ b/docs/0-getting-started/02-installation.md @@ -0,0 +1,136 @@ +--- +title: Installation +description: Tempest can be installed as a standalone PHP project, as well as a package within existing projects. The framework modules can also be installed individually, including in projects built on other frameworks. +--- + +## Prerequisites + +Tempest requires PHP [8.4+](https://www.php.net/downloads.php) and [Composer](https://getcomposer.org/) to be installed. Optionally, you may install either [Bun](https://bun.sh) or [Node](https://nodejs.org) if you chose to bundle front-end assets. + +For a better experience, it is recommended to have a complete development environment, such as [ServBay](https://www.servbay.com), [Herd](https://herd.laravel.com/docs), or [Valet](https://laravel.com/docs/valet). However, Tempest can serve applications using PHP's built-in server just fine. + +Once the prerequisites are installed, you can chose your installation method. Tempest can be a [standalone application](#creating-a-tempest-application), or be added [in an existing project](#tempest-as-a-package)—even one built on top of another framework. + +## Creating a Tempest application + +To get started with a new Tempest project, you may use {`tempest/app`} as the starting point. The `composer create-project` command will scaffold it for you: + +```sh +{:hl-keyword:composer:} create-project tempest/app {:hl-type:my-app:} +{:hl-keyword:cd:} {:hl-type:my-app:} +``` + +If you have a dedicated development environment, you may then access your application by opening `{txt}https://my-app.test` in your browser. Otherwise, you may use PHP's built-in server: + +```sh +{:hl-keyword:php:} tempest serve +{:hl-comment:PHP 8.4.5 Development Server (http://localhost:8000) started:} +``` + +### Scaffolding front-end assets + +Optionally, you may install a basic front-end scaffolding that includes [Vite](https://vite.dev/) and [Tailwind CSS](https://tailwindcss.com/). To do so, run the Vite installer and follow through the wizard: + +```sh +{:hl-keyword:php:} tempest install vite --tailwind +``` + +The assets created by this wizard, `main.entrypoint.ts` and `main.entrypoint.css`, are automatically discovered by Tempest. You can serve them using the [``](../1-essentials/03-views#x-vite-tags) component in your templates. + +You may then [run the front-end development server](../1-essentials/04-asset-bundling#running-the-development-server), which will serve your assets on-the-fly: + +```bash +{:hl-keyword:npm:} run dev +``` + +## Tempest as a package + +If you already have a project, you can opt to install {`tempest/framework`} as a standalone package. You could do this in any project; it could already contain code, or it could be an empty project. + +```sh +{:hl-keyword:composer:} require tempest/framework +``` + +Installing Tempest this way will give you access to the Tempest console, `./vendor/bin/tempest`. Optionally, you can choose to install Tempest's entry points in your project. To do so, you may run the framework installer: + +```txt +{:hl-keyword:./vendor/bin/tempest:} install framework +``` + +This installer will prompt you to install the following files into your project: + +- `public/index.php` — the web application entry point +- `tempest` – the console application entry point +- `.env.example` – a clean example of a `.env` file +- `.env` – the real environment file for your local installation + +You can choose which files you want to install, and you can always rerun the `install` command at a later point in time. + +## Project structure + +Tempest won't impose any file structure on you: one of its core features is that it will scan all project and package code for you, and will automatically discover any files the framework needs to know about. + +For instance, Tempest is able to differentiate between a controller method and a console command by looking at the code, instead of relying on naming conventions or configuration files. + +:::info +This concept is called [discovery](../4-internals/02-discovery), and is one of Tempest's most powerful features. +::: + +The following project structures work the same way in Tempest, without requiring any specific configuration: + +```txt +. . +└── src └── src + ├── Authors ├── Controllers + │ ├── Author.php │ ├── AuthorController.php + │ ├── AuthorController.php │ └── BookController.php + │ └── authors.view.php ├── Models + ├── Books │ ├── Author.php + │ ├── Book.php │ ├── Book.php + │ ├── BookController.php │ └── Chapter.php + │ ├── Chapter.php ├── Services + │ └── books.view.php │ └── PublisherGateway.php + ├── Publishers └── Views + │ └── PublisherGateway.php ├── authors.view.php + └── Support ├── books.view.php + └── x-base.view.php └── x-base.view.php +``` + +## About discovery + +Discovery works by scanning your project code, and looking at each file and method individually to determine what that code does. In production environments, [Tempest will cache the discovery process](../4-internals/02-discovery#discovery-in-production), avoiding any performance overhead. + +As an example, Tempest is able to determine which methods are controller methods based on their route attributes, such as `#[Get]` or `#[Post]`: + +```php app/BlogPostController.php +use Tempest\Router\Get; +use Tempest\Http\Response; +use Tempest\View\View; + +final readonly class BlogPostController +{ + #[Get('/blog')] + public function index(): View + { /* … */ } + + #[Get('/blog/{post}')] + public function show(Post $post): Response + { /* … */ } +} +``` + +Likewise, it is able to detect console commands based on the `#[ConsoleCommand]` attribute: + +```php app/RssSyncCommand.php +use Tempest\Console\HasConsole; +use Tempest\Console\ConsoleCommand; + +final readonly class RssSyncCommand +{ + use HasConsole; + + #[ConsoleCommand('rss:sync')] + public function __invoke(bool $force = false): void + { /* … */ } +} +``` diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md new file mode 100644 index 000000000..6612dc345 --- /dev/null +++ b/docs/1-essentials/01-routing.md @@ -0,0 +1,635 @@ +--- +title: "Routing" +description: "Learn how to route requests to controllers. In Tempest, this is done using attributes, which are automatically discovered by the framework." +--- + +## Overview + +In Tempest, you may associate a route to any class method. Usually, this is done in dedicated controller classes, but it could be any class of your choice. + +Tempest provides many attributes, named after HTTP verbs, to attach URIs to controller actions. These attributes implement the {`Tempest\Router\Route`} interface, so you can write your own if you need to. + +```php app/HomeController.php +use Tempest\Router\Get; +use Tempest\View\View; +use function Tempest\view; + +final readonly class HomeController +{ + #[Get(uri: '/home')] + public function __invoke(): View + { + return view('home.view.php'); + } +} +``` + +Out of the box, an attribute for every HTTP verb is available: {b`Tempest\Router\Get`}, {b`Tempest\Router\Post`}, {b`Tempest\Router\Delete`}, {b`Tempest\Router\Put`}, {b`Tempest\Router\Patch`}, {b`Tempest\Router\Options`}, {b`Tempest\Router\Connect`}, {b`Tempest\Router\Trace`} and {b`Tempest\Router\Head`}. + +## Route parameters + +You may 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. + +```php app/AircraftController.php +use Tempest\Router\Get; +use Tempest\View\View; +use function Tempest\view; + +final readonly class AircraftController +{ + #[Get(uri: '/aircraft/{id}')] + public function show(int $id): View + { + // Fetch the aircraft by ID + $aircraft = $this->aircraftRepository->getAircraftById($id); + + // Pass the aircraft to the view + return view('aircraft.view.php', aircraft: $aircraft); + } +} +``` + +### Regular expression constraints + +You may constrain the format of a route parameter by specifying a regular expression after its name. + +For instance, you may only accept numeric identifiers for an `id` parameter by using the following dynamic segment: `{regex}{id:[0-9]+}`. In practice, a route may look like this: + +```php app/AircraftController.php +use Tempest\Router\Get; +use Tempest\View\View; +use function Tempest\view; + +final readonly class AircraftController +{ + #[Get(uri: '/aircraft/{id:[0-9]+}')] + public function showAircraft(int $id): View + { + // … + } +} +``` + +### Route binding + +In controller actions, you may want to receive an object instead of a scalar value such as an identifier. This is especially useful in the case of [models](./03-database.md#models) to avoid having to write the fetching logic in each controller. + +```php app/AircraftController.php +use Tempest\Router\Get; +use Tempest\Http\Response; +use App\Aircraft; + +final class AircraftController +{ + #[Get('/aircraft/{aircraft}')] + public function show(Aircraft $aircraft): Response { /* … */ } +} +``` + +Route binding may be enabled for any class that implements the {`Tempest\Router\Bindable`} interface, which requires a `resolve()` method responsible for returning the correct instance. + +```php +use Tempest\Router\Bindable; +use Tempest\Database\IsDatabaseModel; + +final class Aircraft implements Bindable +{ + use IsDatabaseModel; + + public function resolve(string $input): self + { + return self::find(id: $input); + } +} +``` + +### Backed enum binding + +You may inject string-backed enumerations to controller actions. Tempest will try to map the corresponding parameter from the URI to an instance of that enum using the [`tryFrom`](https://www.php.net/manual/en/backedenum.tryfrom.php) enum method. + +```php app/AircraftController.php +use Tempest\Router\Get; +use Tempest\Http\Response; +use App\AircraftType; + +final readonly class AircraftController +{ + #[Get('/aircraft/{type}')] + public function show(AircraftType $type): Response { /* … */ } +} +``` + +In the example above, we inject an `AircraftType` enumeration. If the request's `type` parameter has a value specified in that enumeration, it will be passed to the controller action. Otherwise, a HTTP 404 response will be returned without entering the controller method. + +```php app/AircraftType.php +enum AircraftType: string +{ + case PC12 = 'pc12'; + case PC24 = 'pc24'; + case SF50 = 'sf50'; +} +``` + +### Regex parameters + +You may use regular expressions to match route parameters. This can be useful to create catch-all routes or to match a route parameter to any kind of regex pattern. Add a colon `:` followed by a pattern to the parameter's name to indicate that it should be matched using a regular expression. + +```php +#[Get('/main/{path:.*}')] +public function docsRedirect(string $path): Redirect +{ + // … +} +``` + +## Generating URIs + +Tempest provides a `\Tempest\uri` function that can be used to generate an URI to a controller method. This function accepts the FQCN of the controller or a callable to a method as its first argument, and named parameters as [the rest of its arguments](https://www.php.net/manual/en/functions.arguments.php#functions.variable-arg-list). + +```php +use function Tempest\uri; + +// Invokable classes can be referenced directly: +uri(HomeController::class); +// /home + +// Classes with named methods are referenced using an array +uri([AircraftController::class, 'store']); +// /aircraft + +// Additional URI parameters are passed in as named arguments: +uri([AircraftController::class, 'show'], id: $aircraft->id); +// /aircraft/1 +``` + +:::info +Note that Tempest does not have named routes, and currently doesn't plan on adding them. However, if you have an argument for them, feel free to hop on our [Discord server](/discord){:ssg-ignore="true"} to discuss it. +::: + +## Matching the current URI + +To determine whether the current request matches a specific controller action, Tempest provides the `\Tempest\is_current_uri` function. This function accepts the same arguments as `uri`, and returns a boolean. + +```php +use function Tempest\is_current_uri; + +// Current URI is: /aircraft/1 + +// Providing no argument to the right controller action will match +is_current_uri(AircraftController::class); // true + +// Providing the correct arguments to the right controller action will match +is_current_uri(AircraftController::class, id: 1); // true + +// Providing invalid arguments to the right controller action will not match +is_current_uri(AircraftController::class, id: 2); // false +``` + +## Accessing request data + +A core pattern of any web application is to access data from the current request. You may do so by injecting {`Tempest\Http\Request`} to a controller action. This class provides access to the request's body, query parameters, method, and other attributes through dedicated class properties. + +### Using request classes + +In most situations, the data you expect to receive from a request is structured. You expect clients to send specific values, and you want them to follow specific rules. + +The idiomatic way to achieve this is by using request classes. They are classes with public properties that correspond to the data you want to retrieve from the request. Tempest will automatically validate these properties using PHP's type system, in addition to optional [validation attributes](../2-features/06-validation) if needed. + +A request class must implement {`Tempest\Http\Request`} and should use the {`Tempest\Http\IsRequest`} trait, which provides the default implementation. + +```php app/RegisterAirportRequest.php +use Tempest\Http\Request; +use Tempest\Http\IsRequest; +use Tempest\Validation\Rules\Length; + +final class RegisterAirportRequest implements Request +{ + use IsRequest; + + #[Length(min: 10, max: 120)] + public string $name; + + public ?DateTimeImmutable $registeredAt = null; + + public string $servedCity; +} +``` + +:::info Interfaces with default implementations +Tempest uses this pattern a lot. Most classes that interact with the framework need to implement an interface, and a corresponding trait with a default implementation will be provided. +::: + +Once you have created a request class, you may simply inject it into a controller action. Tempest will take care of filling its properties and validating them, leaving you with a properly-typed object to work with. + +```php app/AirportController.php +use Tempest\Router\Post; +use Tempest\Http\Responses\Redirect; +use function Tempest\map; +use function Tempest\uri; + +final readonly class AirportController +{ + #[Post(uri: '/airports/register')] + public function store(RegisterAirportRequest $request): Redirect + { + $airport = map($request)->to(Airport::class)->save(); + + return new Redirect(uri([self::class, 'show'], id: $airport->id)); + } +} +``` + +:::info A note on data mapping +The `map()` function allows mapping any data from any source into objects of your choice. You may read more about them in [their documentation](../2-features/01-mapper.md). +::: + +### Retrieving data directly + +For simpler use cases, you may simply retrieve a value from the body or the query parameter using the request's `get` method. + +```php app/AircraftController.php +use Tempest\Router\Get; +use Tempest\Http\Request; + +final readonly class AircraftController +{ + #[Get(uri: '/aircraft')] + public function me(Request $request): View + { + $icao = $request->get('icao'); + // … + } +} +``` + +## Form validation + +Oftentimes you'll want to submit form data from a website to be processed in the backend. In the previous section we've explained that Tempest will automatically map and validate request data unto request objects, but how do you then show validation errors back on the frontend? + +Whenever a validation error occurs, Tempest will redirect back to the page the request was submitted on, or send a 400 invalid response (in case you're sending API requests). The validation errors can be found in two places: + +- As a JSON encoded string in the `{txt}X-Validation` header +- Within the session with the `Session::VALIDATION_ERRORS` key + +The JSON encoded header is available for when you're building APIs with Tempest. The session errors are available for when you're building web pages. In the case of the latter, you need a way to actually show the errors on a web page. Tempest's recommended way to do so is by creating a custom [view component](/docs/essentials/views#view-components): + +```html app/x-error.view.php +get(Session::VALIDATION_ERRORS)[$name ?? null] ?? null; +?> + +
+
+
+
+ {{ $message }} +
+
+
+ {{ $error->message() }} +
+
+
+``` + +This view component will be discovered and can then be used to display validation errors likes so: + +```html +
+ + + + + + +``` + +:::info +Currently, Tempest doesn't include built-in view components to handle form validation. That's because we don't have a strategy yet for dealing with different frontend frameworks. We rather give control to the user to build their own form components for maximum flexibility. This is likely to change in the future, but for now you'll have to make your own `x-error` component. +::: + +## Route middleware + +Middleware can be applied to handle tasks in between receiving a request and sending a response. To specify a middleware for a route, add it to the `middleware` argument of a route attribute. + +```php app/ReceiveInteractionController.php +use Tempest\Router\Get; +use Tempest\Http\Response; + +final readonly class ReceiveInteractionController +{ + #[Post('/slack/interaction', middleware: [ValidateWebhook::class])] + public function __invoke(): Response + { + // … + } +} +``` + +The middleware class must be an invokable class that implements the {`Tempest\Router\HttpMiddleware`} interface. This interface has an `{:hl-property:__invoke:}()` method that accepts the current request as its first parameter and {`Tempest\Router\HttpMiddlewareCallable`} as its second parameter. + +`HttpMiddlewareCallable` is an invokable class that forwards the `$request` to its next step in the pipeline. + +```php +use Tempest\Router\HttpMiddleware; +use Tempest\Router\HttpMiddlewareCallable; +use Tempest\Http\Request; +use Tempest\Http\Response; +use Tempest\Discovery\SkipDiscovery; +use Tempest\Core\Priority; + +#[SkipDiscovery] +#[Priority(Priority::LOW)] +final readonly class ValidateWebhook implements HttpMiddleware +{ + public function __invoke(Request $request, HttpMiddlewareCallable $next): Response + { + $signature = $request->headers->get('X-Slack-Signature'); + $timestamp = $request->headers->get('X-Slack-Request-Timestamp'); + + // … + + return $next($request); + } +} +``` + +### Middleware priority + +All middleware classes get sorted based on their priority. By default, each middleware gets the "normal" priority, but you can override it using the `#[Priority]` attribute: + +```php +use Tempest\Core\Priority; + +#[Priority(Priority::HIGH)] +final readonly class ValidateWebhook implements HttpMiddleware +{ /* … */ } +``` + +Note that priority is defined using an integer. You can however use one of the built-in `Priority` constants: `Priority::FRAMEWORK`, `Priority::HIGHEST`, `Priority::HIGH`, `Priority::NORMAL`, `Priority::LOW`, `Priority::LOWEST`. + +### Middleware discovery + +Global middleware classes are discovered and sorted based on their priority. You can make a middleware class non-global by adding the `#[SkipDiscovery]` attribute: + +```php +use Tempest\Discovery\SkipDiscovery; + +#[SkipDiscovery] +final readonly class ValidateWebhook implements HttpMiddleware +{ /* … */ } +``` + +## Responses + +All requests to a controller action expect a response to be returned to the client. This is done by returning a `{php}View` or a `{php}Response` object. + +### View responses + +Returning a view is a shorthand for returning a successful response with that view. You may as well use the `{php}view()` function directly to construct a view. + +```php app/Aircraft/AircraftController.php +use Tempest\Router\Get; +use Tempest\View\View; +use function Tempest\view; + +final readonly class AircraftController +{ + #[Get(uri: '/aircraft/{aircraft}')] + public function show(Aircraft $aircraft, User $user): View + { + return view('./show.view.php', + aircraft: $aircraft, + user: $user, + ); + } +} +``` + +Tempest has a powerful templating system inspired by modern front-end frameworks. You may read more about views in their [dedicated chapter](./02-views.md). + +### Using built-in response classes + +Tempest provides several classes, all implementing the {`Tempest\Http\Response`} interface, mostly named after HTTP statuses. + +- `{php}Ok` — the 200 response. Accepts an optional body. +- `{php}Created` — the 201 response. Accepts an optional body. +- `{php}Redirect` — redirects to the specified URI. +- `{php}Back` — redirects to previous page, accepts a fallback. +- `{php}Download` — downloads a file from the browser. +- `{php}File` — shows a file in the browser. +- `{php}Invalid` — a response with form validation errors, redirecting to the previous page. +- `{php}NotFound` — the 404 response. Accepts an optional body. +- `{php}ServerError` — a 500 server error response. + +The following example conditionnally returns a `Redirect`, otherwise letting the user download a file by sending a `Download` response: + +```php app/FlightPlanController.php +use Tempest\Router\Get; +use Tempest\Http\Responses\Download; +use Tempest\Http\Responses\Redirect; +use Tempest\Http\Response; + +final readonly class FlightPlanController +{ + #[Get('/{flight}/flight-plan/download')] + public function download(Flight $flight): Response + { + $allowed = /* … */; + + if (! $allowed) { + return new Redirect('/'); + } + + return new Download($flight->flight_plan_path); + } +} +``` + +### Sending generic responses + +It might happen that you need to dynamically compute the response's status code, and would rather not use a condition to send the corresponding response object. + +You may then return an instance of {`Tempest\Http\GenericResponse`}, specifying the status code and an optional body. + +```php app/CreateFlightController.php +use Tempest\Router\Get; +use Tempest\Http\Responses\Download; +use Tempest\Http\Responses\Redirect; +use Tempest\Http\GenericResponse; +use Tempest\Http\Response; + +final readonly class CreateFlightController +{ + #[Post('/{flight}')] + public function __invoke(Flight $flight): Response + { + $status = /* … */ + $body = /* … */ + + return new GenericResponse( + status: $status, + body: $body, + ); + } +} +``` + +### Using custom response classes + +There are situations where you might send the same kind of response in a lot of places, or you might want to have a proper API for sending a structured response. + +You may create your own response class by implementing {`Tempest\Http\Response`}, which default implementation is provided by the {`Tempest\Http\IsResponse`} trait: + +```php app/AircraftRegistered.php +use Tempest\Http\IsResponse; +use Tempest\Http\Response; +use Tempest\Http\Status; + +final class AircraftRegistered implements Response +{ + use IsResponse; + + public function __construct(Aircraft $aircraft) + { + $this->status = Status::CREATED; + $this->flash( + key: 'success', + value: "Aircraft {$aircraft->icao_code} was successfully registered." + ); + } +} +``` + +### Specifying content types + +Tempest is able to automatically infer the response's content type, usually inferred from the request's `Accept` header. + +However, you may override the content type manually by specifying the `setContentType` method on `Response` clases. This method accepts a case of {`Tempest\Router\ContentType`}. + +```php app/JsonController.php +use Tempest\Router\Get; +use Tempest\Router\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); + } +} +``` + +### Post-processing responses + +There are some situations in which you may need to act on a response right before it is sent to the client. For instance, you may want to display custom error error pages when an exception occurred, or redirect somewhere instead of displaying the [built-in HTTP 404](/hello-from-the-void){:ssg-ignore="true"} page. + +This may be done using a response processor. Similar to [view processors](./02-views.md#pre-processing-views), they are classes that implement the {`Tempest\Response\ResponseProcessor`} interface. In the `process()` method, you may mutate and return the response object: + +```php app/ErrorResponseProcessor.php +use function Tempest\view; + +final class ErrorResponseProcessor implements ResponseProcessor +{ + public function process(Response $response): Response + { + if (! $response->status->isSuccessful()) { + return $response->setBody(view('./error.view.php', status: $response->status)); + } + + return $response; + } +} +``` + +## Custom route attributes + +It is often a requirement to have a bunch of routes following the same specifications—for instance, using the same middleware, or the same URI prefix. + +To achieve this, you may create your own route attribute, implementing the {`Tempest\Router\Route`} interface. The constructor of the attribute may hold the logic you want to apply to the routes using it. + +```php app/RestrictedRoute.php +use Attribute; +use Tempest\Http\Method; +use Tempest\Router\Route; + +#[Attribute] +final readonly class RestrictedRoute implements Route +{ + public function __construct( + public string $uri, + public Method $method, + public array $middleware, + ) { + $this->uri = $uri; + $this->method = $method; + $this->middleware = [ + AuthorizeUserMiddleware::class, + LogUserActionsMiddleware::class, + ...$middleware, + ]; + } +} +``` + +This attribute can be used in place of the usual route attributes, on controller action methods. + +## Deferring tasks + +It is sometimes needed, during requests, to perform tasks that would take a few seconds to complete. This could be sending an email, or keeping track of a page visit. + +Tempest provides a way to perform that task after the response has been sent, so the client doesn't have to wait until its completion. This is done by passing a callback to the `defer` function: + +```php app/TrackVisitMiddleware.php +use Tempest\Router\HttpMiddleware; +use Tempest\Router\HttpMiddlewareCallable; +use Tempest\Http\Request; +use Tempest\Http\Response; + +use function Tempest\defer; +use function Tempest\event; + +final readonly class TrackVisitMiddleware implements HttpMiddleware +{ + public function __invoke(Request $request, HttpMiddlewareCallable $next): Response + { + defer(fn () => event(new PageVisited($request->getUri()))); + + return $next($request); + } +} +``` + +The `defer` callback may accept any parameter that the container can inject. + +:::warning +Task deferring only works if [`fastcgi_finish_request()`](https://www.php.net/manual/en/function.fastcgi-finish-request.php) 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. +::: + +## Testing + +Tempest provides a router testing utility accessible through the `http` property of the [`IntegrationTest`](https://github.com/tempestphp/tempest-framework/blob/main/src/Tempest/Framework/Testing/IntegrationTest.php) test case. You may learn more about testing in the [dedicated chapter](./07-testing.md). + +The router testing utility provides methods for all HTTP verbs. These method return an instance of [`TestResponseHelper`](https://github.com/tempestphp/tempest-framework/blob/main/src/Tempest/Framework/Testing/Http/TestResponseHelper.php), giving access to multiple assertion methods. + +```php tests/ProfileControllerTest.php +final class ProfileControllerTest extends IntegrationTestCase +{ + public function test_can_render_profile(): void + { + $response = $this->http + ->get('/account/profile') + ->assertOk() + ->assertSee('My Profile'); + } +} +``` diff --git a/docs/1-essentials/02-views.md b/docs/1-essentials/02-views.md new file mode 100644 index 000000000..34319a28e --- /dev/null +++ b/docs/1-essentials/02-views.md @@ -0,0 +1,739 @@ +--- +title: Views +description: "Tempest provides a modern templating engine with syntax inspired by the best front-end frameworks. However, Blade, Twig or any other engine can be used if you prefer so." +keywords: "Experimental" +--- + +:::warning +Tempest View is currently experimental and is not covered by our backwards compatibility promise. +::: + +## Overview + +Views in Tempest are parsed by Tempest View, our own templating engine. Tempest View uses a syntax that can be thought of as a superset of HTML. If you prefer using a templating engine with more widespread support, [you may also use Blade, Twig, or any other](#using-other-engines)—as long as you provide a way to initialize it. + +### Syntax overview + +The following is an example of a view that inherits the `x-base` component, passing a `title` property. + +Inside, a `x-post` [component](#view-components) is rendered multiple times thanks to a [foreach loop](#foreach-and-forelse) on `$this->posts`. That component has a default [slot](#using-slots), in which the post details are rendered. The [control flow](#control-flow-directives) is implemented using HTML attributes that start with colons `:`. + +```html + + + {{-- a comment which won't be rendered to HTML --}} + + {!! $post->title !!} + + + {{ $post->date }} + + + - + + +
+

It's quite empty here…

+
+ + +
+``` + +## Rendering views + +As specified in the documentation about [sending responses](./01-routing.md#view-responses), views may be returned from controller actions using the `{php}view` function. This function is a shorthand for instantiating a {`Tempest\View\View`} object. + +```php app/AircraftController.php +use Tempest\Router\Get; +use Tempest\View\View; +use function Tempest\view; + +final readonly class AircraftController +{ + #[Get(uri: '/aircraft/{aircraft}')] + public function show(Aircraft $aircraft): View + { + return view('aircraft.view.php', aircraft: $aircraft); + } +} +``` + +### View paths + +The `view` function accepts the path to a view as its first parameter. This path may be relative or absolute, depending on your preference. + +The following three examples are equivalent: + +```php +return view(__DIR__ . '/views/home.view.php'); +return view('./views/home.view.php'); +return view('views/home.view.php'); +``` + +### Using dedicated view objects + +A view object is a dedicated class that represent a specific view. + +Using view objects will improve static insights in your controllers and view files, and may offer more flexibiltiy regarding how the data may be constructed before being passed on to a view file. + +```php +final class AircraftController +{ + #[Get('/aircraft/{type}/{aircraft}')] + public function show(AircraftType $type, Aircraft $aircraft): AircraftView + { + return new AircraftView($aircraft, $type); + } +} +``` + +To create a view object, implement the {`Tempest\View\View`} interface, and add the {`Tempest\View\IsView`} trait, which provides the default implementation. + +```php app/AircraftView.php +use Tempest\View\View; +use Tempest\View\IsView; + +final class AircraftView implements View +{ + use IsView; + + public function __construct( + public Aircraft $aircraft, + public AircraftType $type, + ) { + $this->path = root_path('src/Aircraft/aircraft.view.php'); + } +} +``` + +In a view file rendered by a view object, you may add a type annotation for `$this`. This allows IDEs like [PhpStorm](https://www.jetbrains.com/phpstorm/) to infer variables and methods. + +```html app/Aircraft/aircraft.view.php + + +

+ The {{ $this->aircraft->icao_code }} is a light business jet + produced by Pilatus Aircraft of Switzerland. +

+``` + +View objects are an excellent way of encapsulating view-related logic and complexity, moving it away from controllers, while simultaneously improving static insights. + +## Templating syntax + +### Text interpolation + +Text interpolation is done using the "mustache" syntax. This will escape the given variable or PHP expression before rendering it. + +```html +Welcome, {{ $username }} +``` + +To avoid escaping the data, you may use the following syntax. This should only be used on trusted, sanitized data, as this can open the door to an [XSS vulnerability](https://en.wikipedia.org/wiki/Cross-site_scripting): + +```html +
+ {!! $content !!} +
+``` + +### Expression attributes + +Expression attributes are HTML attributes that are evaluated as PHP code. Their syntax is the same as HTML attributes, except they are identified by a colon `:`: + +```html + + +``` + +As with text interpolation, only variables and PHP expressions that return a value are allowed. Mustache and PHP opening tags cannot be used inside them: + +```html + +

+``` + +When using expression attributes on normal HTML elements, only [scalar](https://www.php.net/manual/en/language.types.type-system.php#language.types.type-system.atomic.scalar) and `Stringable` values can be returned. However, any object can be passed down to a [component](#view-components). + +### Boolean attributes + +The HTML specification describes a special kind of attributes called [boolean attributes](https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attribute). These attributes don't have a value, but indicate `true` whenever they are present. + +Using an expression attribute that return a boolean variable will follow the HTML specification, effectively not rendering the attribute if the value is `false`. + +```html + +``` + +Depending on whether `$selected` evaluates to `true` or `false`, the above example may or may not render the `selected` attribute. + +Apart from HTMLs boolean attributes, the same syntax can be used with any expression attribute as well: + +```html +
+ + + +``` + +### Control flow directives + +#### `:if`, `:elseif` and `:else` + +The `:if` directive can conditionally render the element it is attached to, depending on the result of its expression. Similarly, the `:elseif` and `:else` directives can be used on direct siblings for additional control. + +```html +Import files +Import {{ $this->pendingUploads->count() }} file(s) +``` + +#### `:foreach` and `:{:hl-keyword:forelse:}` + +The `:foreach` directive may be used to render the associated element multiple times based on the result of its expression. Combined with `:{:hl-keyword:forelse:}`, an empty state can be displayed when the data is empty. + +```html +
  • + {{ $report->title }} +
  • +
  • + There is no report. +
  • +``` + +### Templates + +The built-in `{html}` element may be used as a placeholder when you want to use a directive without rendering an actual element in the DOM. + +```html + +
    {{ $post->title }}
    +
    +``` + +The example above will only render the child `div` elements: + +```html +
    Post A
    +
    Post B
    +
    Post C
    +``` + +## View components + +Components allow for splitting the user interface into independent and reusable pieces. + +Tempest doesn't have a concept of extending other views. Instead, a component may include another component using the same syntax as other HTML elements. + +### Registering view components + +To create a view component, create a `.view.php` file that starts with `x-`. These files are referred to as anonymous view components and are automatically discovered by Tempest. + +```html app/x-base.view.php + + + {{ $title }} — AirAcme + AirAcme + + + + + +``` + +### Using view components + +All views may include a views components. In order to do so, you may simply use a component's name as a tag, including the `x-` prefix: + +```html app/home.view.php + +
    + {{ $this->post->body }} +
    +
    +``` + +The example above demonstrates how to pass data to a component using an [expression attribute](#expression-attributes), as well as how pass elements as children if that component where the `` tag is used. + +### Attributes in components + +Attributes and [expression attributes](#expression-attributes) may be passed into view components. They work the same way as normal elements, and their values will be available in variables of the same name: + +```html home.view.php + + // ... + +``` + +```html x-base.view.php +// ... +{{ $title }} +``` + +Note that the casing of attributes will affect the associated variable name: + +- `{txt}camelCase` and `{txt}PascalCase` attributes will be converted to `$lowercase` variables +- `{txt}kebab-case` and `{txt}snake_case` attributes will be converted to `$camelCase` variables. + +:::info +The idiomatic way of using attributes is to always use `{txt}kebab-case`. +::: + +### Fallthrough attributes + +When `{html}class` and `{html}style` attributes are used on a view component, they will automatically be added to the root node, or merged with the existing attribute if it already exists. + +```html x-button.view.php + +``` + +The example above defines a button component with a default set of classes. Using this component and providing another set of classes will merge them together: + +```html index.view.php + +``` + +Similarly, the `id` attribute will always replace an existing `id` attribute on the root node of a view component. + +### Dynamic attributes + +An `$attributes` variable is accessible within view components. This variable is an array that contains all attributes passed to the component, except expression attributes. + +Note that attributes names use `{txt}kebab-case`. + +```html x-badge.view.php + + {{ $attributes['value'] }} + +``` + +### Using slots + +The content of components is often dynamic, depending on external context to be rendered. View components may define zero or more slot outlets, which may be used to render the given HTML fragments. + +```html x-button.view.php + +``` + +The example above defines a button component with default classes, and a slot inside. This component may be used like a normal HTML element, providing the content that will be rendered in the slot outlet: + +```html index.view.php + + + + Delete + +``` + +### Default slot content + +A view component's slot can define a default value, which will be used when a view using that component doesn't pass any value to it: + +```html x-my-component.view.php +
    + Fallback value + Fallback value for named slot +
    +``` + +```html + + + +``` + +### Named slots + +When a single slot is not enough, names can be attached to them. When using a component with named slot, you may use the `` tag with a `name` attribute to render content in a named outlet: + +```html x-base.view.php + + + + + + + + + +``` + +The above example uses a slot named `styles` in its `` element. The `` element has a default, unnamed slot. A view component may use `` and optionally refer to the `styles` slot using the syntax mentionned above, or simply provide content that will be injected in the default slot: + +```html index.view.php + + + + + + + +

    + Hello World +

    +
    +``` + +### Dynamic slots + +Within a view component, a `$slots` variable will always be provided, allowing you to dynamically access the named slots within the component. + +This variable is an instance of {`Tempest\View\Slot`}, with has a handful of properties: + +- `{php}$slot->name`: the slot's name +- `{php}$slot->content`: the compiled content of the slot +- `{php}$slot->attributes`: all the attributes defined on the slot +- `{php}$slot->{attribute}`: dynamically access an attribute defined on the slot + +For instance, the snippet below implements a tab component that accepts any number of tabs. + +```html x-tabs.view.php +
    +

    {{ $slot->name }}

    +

    {!! $slot->content !!}

    +
    +``` + +```html + + This is the PHP tab + This is the JavaScript tab + This is the HTML tab + +``` + +### Dynamic view components + +On some occasions, you might want to dynamically render view components, for example, render a view component whose name is determined at runtime. You can use the `{html}` element to do so: + +```html + + + +``` + +### View component scope + +View components act almost exactly the same as PHP's closures: they only have access to the variables you explicitly provide them, and any variable defined within a view component won't leak into the out scope. + +The only difference with normal closures is that view components also have access to view-defined variables as local variables. + +```html + + + + + +``` + +```php +/* View-defined data will be available within the component directly */ +final class HomeController +{ + #[Get('/')] + public function __invoke(): View + { + return view('', siteTitle: 'Tempest'); + } +} +``` + +```html x-base.view.php + +

    {{ $siteTitle }}

    +``` + +## Built-in components + +Besides components that you may create yourself, Tempest provides a default set of useful built-in components to improve your developer experience. Any vendor-provided component can be published in your own project by running the `tempest install` command: + +```console +./tempest install view-components + + Select which view components you want to install + / Filter... + → ⋅ x-csrf-token + ⋅ x-markdown + ⋅ x-input + ⋅ x-icon + + +``` + +Any component with the same name that lives in your local project will get precedence over vendor-defined components. + +### `x-base` + +A base template you can install into your own project as a starting point. This one includes the Tailwind CDN for quick prototyping. + +```html + +

    Welcome!

    +
    +``` + +### `x-form` + +This component provides a form element that will post by default and includes the csrf token out of the box: + +```html + + + + + +``` + +### `x-input` + +A versatile input component that will render labels and validation errors automatically. + +```html + + + +``` + +### `x-submit` + +A submit button component that prefills with a "Submit" label: + +```html + + +``` + +### `x-csrf-token` + +Includes the CSRF token in a form + +```html +
    + + +``` + +### `x-icon` + +This component provides the ability to inject any icon from the [Iconify](https://iconify.design/) project in your templates. + +```html + +``` + +The first time a specific icon is being rendered, Tempest will query the [Iconify API](https://iconify.design/docs/api/queries.html) to fetch the corresponding SVG tag. The result of this query will be cached indefinitely, so it can be reused at no further cost. + +:::info +Iconify has a large collection of icon sets, which you may browse using the [Icônes](https://icones.js.org/) directory. +::: + +### `x-vite-tags` + +Tempest has built-in support for [Vite](https://vite.dev/), the most popular front-end development server and build tool. You may read more about [asset bundling](../2-features/05-asset-bundling.md) in the dedicated documentation. + +This component simply inject registered entrypoints where it is called. + +```html x-base.view.php + + + + + + +``` + +Optionally, it accepts an `entrypoint` attribute. If it is passed, the component will not inject other entrypoints discovered by Tempest. + +```html x-base.view.php + +``` + +### `x-markdown` + +The `{html}x-markdown` component can be used to render markdown content, either directly from your view files, or by passing a content variables into it: + +```html +# hi + +``` + +## Pre-processing views + +In most applications, some views will need access to common data. To avoid having to manually provide this data to views through controller methods, it is possible to use view processors to manipulate views before they are rendered. + +To create a view processor, create a class that implements the {`Tempest\View\ViewProcessor`} interface. It requires a `process()` method in which you may mutate and return the view that will be rendered. + +```php +use Tempest\View\View; +use Tempest\View\ViewProcessor; + +final class StarCountViewProcessor implements ViewProcessor +{ + public function __construct( + private readonly GitHub $github, + ) {} + + public function process(View $view): View + { + if (! $view instanceof WithStargazersCount) { + return $view; + } + + return $view->data(stargazers: $this->github->getStarCount()); + } +} +``` + +The example above provides the `$stargazers` variable to all view classes that implement the `WithStargazersCount` interface. + +## View caching + +Tempest views are always compiled to plain PHP code before being rendered. During development, this is done on-the-fly, every time. In production, these compiled views should be cached to avoid the performance overhead. This is done by setting the `{txt}{:hl-property:VIEW_CACHE:}` environment variable: + +```env .env +{:hl-property:VIEW_CACHE:}={:hl-keyword:true:} +``` + +During deployments, that cache must be cleared in order to not serve outdated views to users. You may do that by running `tempest view:clear` on every deploy. + +## Using other engines + +While Tempest View is simple to use, it currently lacks tooling support from editors and IDEs. You may also simply prefer other templating engines. For these reasons, you may use any other engine of your choice. + +Out-of-the-box, Tempest has support for Twig and Blade. Note that the view loaders for other engines are not based on Tempest's discovery, so the syntax to refer to a specific view might differ. + +### Using Twig + +You will first need to install the Twig engine. It is provided by the `twig/twig` package: + +```sh +composer require twig/twig +``` + +The next step is to provide the configuration needed for Twig to find your view files. + +```php app/twig.config.php +return new TwigConfig( + viewPaths: [ + __DIR__ . '/views/', + ], +); +``` + +Finally, update the view configuration to use the Twig renderer: + +```php view.config.php +return new ViewConfig( + rendererClass: \Tempest\View\Renderers\TwigViewRenderer::class, +); +``` + +### Using Blade + +You will first need to install the Blade engine. Tempest provides a bridge distributed as `tempest/blade`: + +``` +composer require tempest/blade +``` + +The next step is to provide the configuration needed for Blade to find your view files. + +```php blade.config.php +return new BladeConfig( + viewPaths: [ + __DIR__ . '/views/', + ], +); +``` + +Finally, update the view configuration to use the Blade renderer: + +```php view.config.php +return new ViewConfig( + rendererClass: \Tempest\View\Renderers\BladeViewRenderer::class, +); +``` + +### Using something else + +Tempest refers to the view configuration to determine which view renderer should be used. By default, it uses Tempest View's renderer, {`Tempest\View\Renderers\TempestViewRenderer`}. When using Blade or Twig, we provided {`Tempest\View\Renderers\BladeViewRenderer`} or {`Tempest\View\Renderers\TwigViewRenderer`}, respectively. + +#### Implementing your own renderer + +If you prefer using another templating engine, you will need to write your own renderer by implementing the {`Tempest\View\ViewRenderer`} interface. + +This interface only requires a `render` method. It will be responsible for taking a {`Tempest\View\View`} instance and rendering it to a PHP file. + +As an example, the Blade renderer is as simple as the following: + +```php +use Tempest\Blade\Blade; +use Tempest\View\View; +use Tempest\View\ViewRenderer; + +final readonly class BladeViewRenderer implements ViewRenderer +{ + public function __construct( + private Blade $blade, + ) { + } + + public function render(View|string|null $view): string + { + return $this->blade->render($view->path, $view->data); + } +} +``` + +Once your renderer is implemented, you will need to configure Tempest to use it. This is done by creating or updating a `ViewConfig`: + +```php view.config.php +return new ViewConfig( + rendererClass: YourOwnViewRenderer::class, +); +``` + +#### Initializing your engine + +The renderer will be called every time a view is rendered. If your engine has an initialization step, it may be a good idea to use a singleton [initializer](../1-essentials/05-container.md#dependency-initializers) to construct it. + +As an example, here is a simplified version of the initializer that creates the `Blade` object, used by the Blade renderer: + +```php +use Tempest\Blade\Blade; +use Tempest\Container\Container; +use Tempest\Container\DynamicInitializer; +use Tempest\Container\Singleton; +use Tempest\Reflection\ClassReflector; + +final readonly class BladeInitializer implements DynamicInitializer +{ + public function canInitialize(ClassReflector $class): bool + { + return $class->getName() === Blade::class; + } + + #[Singleton] + public function initialize(ClassReflector $class, Container $container): object + { + $bladeConfig = $container->get(BladeConfig::class); + + return new Blade( + viewPaths: $bladeConfig->viewPaths, + ); + } +} +``` diff --git a/docs/1-essentials/03-database.md b/docs/1-essentials/03-database.md new file mode 100644 index 000000000..61e1ff2e2 --- /dev/null +++ b/docs/1-essentials/03-database.md @@ -0,0 +1,803 @@ +--- +title: Database +description: "Tempest's database component allows you to persist data to SQLite, MySQL and PostgreSQL databases. You can use our powerful query builder, or build truly decoupled models to interact with your database of choice." +keywords: ["experimental", "orm", "database", "sqlite", "postgresql", "pgsql", "mysql", "query", "sql", "connection", "models"] +--- + +:::warning +Tempest's database component is currently experimental and is not covered by our backwards compatibility promise. +::: + +## Connecting to a database + +By default, Tempest will connect to a local SQLite database located in its internal storage, `.tempest/database.sqlite`. You may override the default database connection by creating a [configuration file](../1-essentials/06-configuration.md#configuration-files): + +```php app/Config/database.config.php +use Tempest\Database\Config\SQLiteConfig; +use function Tempest\root_path; + +return new SQLiteConfig( + path: root_path('database.sqlite'), +); +``` + +Alternatively, you can connect to another database by returning another configuration object from file. You may choose between {b`Tempest\Database\Config\SQLiteConfig`}, {b`Tempest\Database\Config\MysqlConfig`}, or {b`Tempest\Database\Config\PostgresConfig`}: + +```php app/Config/database.config.php +use Tempest\Database\Config\PostgresConfig; +use function Tempest\env; + +return new PostgresConfig( + host: env('DATABASE_HOST', default: '127.0.0.1'), + port: env('DATABASE_PORT', default: '5432'), + username: env('DATABASE_USERNAME', default: 'postgres'), + password: env('DATABASE_PASSWORD', default: 'postgres'), + database: env('DATABASE_DATABASE', default: 'postgres'), +); +``` + +## Querying the database + +There are multiple ways to query the database, but all of them eventually do the same thing: execute a {b`Tempest\Database\Query`} on the {b`Tempest\Database\Database`} class. The most straight-forward way to query the database is thus by injecting {b`Tempest\Database\Database`}: + +```php +use Tempest\Database\Database; +use Tempest\Database\Query; + +final class BookRepository +{ + public function __construct( + private readonly Database $database, + ) {} + + public function findById(int $id): array + { + return $this->database->fetchFirst(new Query( + sql: 'SELECT id, title FROM books WHERE id = ?', + bindings: [$id], + )); + } +} +``` + +Manually building and executing queries gives you the most flexibility. However, using Tempest's query builder is more convenient—it gives you fluent methods to build queries without needing to worry about database-specific syntax differences. + +```php +use function Tempest\Database\query; + +final class BookRepository +{ + public function findById(int $id): array + { + return query('books') + ->select('id', 'title') + ->where('id = ?', $id) + ->first(); + } +} +``` + +If preferred, you can combine both methods and use the query builder to build a query that's executed on a database: + +```php +use Tempest\Database\Database; +use function Tempest\Database\query; + +final class BookRepository +{ + public function __construct( + private readonly Database $database, + ) {} + + public function findById(int $id): array + { + return $this->database->fetchFirst( + query('books') + ->select('id', 'title') + ->where('id = ?', $id), + ); + } +} +``` + +### Query builders + +There are multiple types of query builders, all of them are available via the `query()` function. If you prefer to manually create a query builder though, you can also instantiate them directly: + +```php +use Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder; + +$builder = new SelectQueryBuilder('books'); +``` + +Currently, there are five query builders shipped with Tempest: + +- {`Tempest\Database\Builder\QueryBuilders\SelectQueryBuilder`} +- {`Tempest\Database\Builder\QueryBuilders\InsertQueryBuilder`} +- {`Tempest\Database\Builder\QueryBuilders\UpdateQueryBuilder`} +- {`Tempest\Database\Builder\QueryBuilders\DeleteQueryBuilder`} +- {`Tempest\Database\Builder\QueryBuilders\CountQueryBuilder`} + +Each of them has their own unique methods that work within their scope. You can discover them via your IDE, or check them out on [GitHub](https://github.com/tempestphp/tempest-framework/tree/main/packages/database/src/Builder/QueryBuilders). + +Finally, you can make your own query builders if you want by implementing the {b`Tempest\Database\Builder\QueryBuilders\BuildsQuery`} interface. + +## Models + +A common use case in many applications is to represent persisted data as objects within your codebase. This is where model classes come in. Tempest tries to decouple models as best as possible from the database, so any object with public typed properties can represent a model. + +These objects don't have to implement any interface—they may be plain-old PHP objects: + +```php app/Book.php +use Tempest\Validation\Rules\Length; +use App\Author; + +final class Book +{ + #[Length(min: 1, max: 120)] + public string $title; + + public ?Author $author = null; + + /** @var \App\Chapter[] */ + public array $chapters = []; +} +``` + +Because model objects aren't tied to the database specifically, Tempest's [mapper](../2-features/01-mapper.md) can map data from many different sources to them. For instance, you can persist your models as JSON instead of a database, if you want to: + +```php +use function Tempest\map; + +$books = map($json)->collection()->to(Book::class); // from JSON source to Book collection +$json = map($books)->toJson(); // from Book collection to JSON +``` + +That being said, persistence most often happens on the database level, so let's take a look at how to deal with models that persist to the database. + +### Models and query builders + +The easiest way of persisting models to a database is by using the query builder. Tempest's query builder cannot just deal with tables and arrays, but also knows how to map data from and to model objects. All you need to do is specify which class you want to query, and Tempest will do the rest. + +In the following example, we'll query the table related to the `Book` model, we'll select all fields, load its related `chapters` and `author` as well, specify the ID of the book we're searching, and then return the first result: + +```php +use App\Models\Book; +use function Tempest\Database\query; + +final class BookRepository +{ + public function findById(int $id): Book + { + return query(Book::class) + ->select() + ->with('chapters', 'author') + ->where('id = ?', $id) + ->first(); + } +} +``` + +Tempest will infer all relation-type information from the model class, specifically by looking at the property types. For example, a property with the `Author` type is assumed to be a "belongs to" relation, while a property with the `/** @var \App\Chapter[] */` docblock is assumed to be a "has many" relation on the `Chapter` model. + +Apart from selecting models, it's of course possible to use any other query builder with them as well: + +```php +use App\Models\Book; +use function Tempest\Database\query; + +final class BookRepository +{ + public function create(Book $book): Id + { + return query(Book::class) + ->insert($book) + ->execute(); + } +} +``` + +:::info +Currently it's not possible to insert or update {b`Tempest\Database\HasMany`} or {b`Tempest\Database\HasOne`} relations directly by inserting or updating the parent model. You should first insert or update the parent model and then insert or update the child models separately. This shortcoming will be fixed in [the future](https://github.com/tempestphp/tempest-framework/issues/1087). +::: + +### Model relations + +As mentioned before, Tempest will infer relations based on type information it gets from the model class. A public property with a reference to another class will be assumed to be a {b`Tempest\Database\BelongsTo`} relation, while a property with a docblock that defines an array type is assumed to be a {b`Tempest\Database\HasMany`} relation. + +```php +use App\Author; + +final class Book +{ + // This is a BelongsTo relation: + public ?Author $author = null; + + // This is a HasMany relation: + /** @var \App\Chapter[] */ + public array $chapters = []; +} +``` + +Tempest will infer all the information it needs to build the right queries for you. However, there might be cases where property names and type information don't map one-to-one on your database schema. In that case you can use dedicated attributes to define relations. + +### Relation attributes + +Tempest will infer relation names based on property names and types. However, you can override these names with the {b`#[Tempest\Database\HasMany]`}, {b`#[Tempest\Database\HasOne]`}, and {b`#[Tempest\Database\BelongsTo]`} attributes. These attributes all take two optional arguments: + +- `ownerJoin`, which is used to build the owner's side of join query, +- `relationJoin`, which is used to build the relation's side of the join query. + +```php +use Tempest\Database\BelongsTo; +use Tempest\Database\HasMany; +use Tempest\Database\HasOne; + +final class Book +{ + #[BelongsTo(ownerJoin: 'books.author_uuid', relationJoin: 'authors.uuid')] + public ?Author $author = null; + + /** @var \App\Chapter[] */ + #[HasMany(relationJoin: 'chapters.uuid', ownerJoin: 'books.chapter_uuid')] + public array $chapters = []; + + #[HasOne(relationJoin: 'books.uuid', ownerJoin: 'isbns.book_uuid')] + public Isbn $isbn = []; +} +``` + +The **owner** part of the relation resembles the table that _owns_ the relation. In other words: the table which has a column referencing another table. The **relation** part resembles the table that's _being referenced by another table_. This is why the {b`Tempest\Database\BelongsTo`} relation starts with _the owner join_, while both {b`Tempest\Database\HasMany`} and {b`Tempest\Database\HasOne`} start with _the relation join_. + +Finally, it's important to mention that you don't have to write the full owner or relation join including both the table and the field. You can also use the field name without the table name, in which case the table name is inferred from the related model: + +```php +use Tempest\Database\BelongsTo; +use Tempest\Database\HasMany; +use Tempest\Database\HasOne; + +final class Book +{ + #[BelongsTo(ownerJoin: 'author_uuid', relationJoin: 'uuid')] + public ?Author $author = null; + + /** @var \App\Chapter[] */ + #[HasMany(relationJoin: 'uuid', ownerJoin: 'chapter_uuid')] + public array $chapters = []; + + #[HasOne(relationJoin: 'uuid', ownerJoin: 'book_uuid')] + public Isbn $isbn = []; +} +``` + +### DTO properties + +Sometimes, you might want to store data objects as-is in a table, without there needing to be a relation to another table. To do so, it's enough to add a serializer and caster to the data object's class, and Tempest will know that these objects aren't meant to be treated as database models. Next, you can store the object's data as a json field on the table (see [migrations](#migrations) for more info). + +```php +use Tempest\Database\IsDatabaseModel; +use Tempest\Mapper\CastWith; +use Tempest\Mapper\SerializeWith; +use Tempest\Mapper\Casters\DtoCaster; +use Tempest\Mapper\Serializers\DtoSerializer; + +final class DebugItem +{ + use IsDatabaseModel; + + /* … */ + + public Backtrace $backtrace, +} + +#[CastWith(DtoCaster::class)] +#[SerializeWith(DtoSerializer::class)] +final class Backtrace +{ + // This object won't be considered a relation, + // but rather serialized and stored in a JSON column. + + public array $frames = []; +} +``` + +### Table names + +Tempest will infer the table name for a model class based on the model's classname. By default the table name will by the pluralized, `snake_cased` version of that classname. You can override this name by using the {b`Tempest\Database\Table`} attribute: + +```php +use Tempest\Database\Table; + +#[Table('table_books')] +final class Book +{ + // … +} +``` + +You can also configure a completely new naming strategy for all your models at once by creating a {b`Tempest\Database\Tables\NamingStrategy`} and attaching it to your database config: + +```php +use Tempest\Database\Tables\NamingStrategy; +use function Tempest\Support\str; + +final class PrefixedPascalCaseStrategy implements NamingStrategy +{ + public function getName(string $model): string + { + return 'table_' . str($model) + ->classBasename() + ->pascal() + ->toString(); + } +} +``` + +```php app/Config/database.config.php +use Tempest\Database\Config\SQLiteConfig; + +return new SQLiteConfig( + path: __DIR__ . '/../database.sqlite', + namingStrategy: new PrefixedPascalCaseStrategy(), +); +``` + +### Virtual properties + +By default, all public properties are considered to be part of the model's query fields. To exclude a field from the database mapper, you may use the {b`Tempest\Database\Virtual`} attribute. + +```php +use Tempest\Database\Virtual; +use Tempest\DateTime\DateTime; +use Tempest\DateTime\Duration; + +final class Book +{ + // … + + public DateTime $publishedAt; + + #[Virtual] + public DateTime $saleExpiresAt { + get => $this->publishedAt->add(Duration::days(5))); + } +} +``` + +### The `IsDatabaseModel` trait + +People who are used to Eloquent might prefer a more "active record" style to handling their models. In that case, there's the {b`Tempest\Database\IsDatabaseModel`} trait which you can use in your model classes: + +```php +use Tempest\Database\IsDatabaseModel; +use Tempest\Validation\Rules\Length; +use App\Author; + +final class Book +{ + use IsDatabaseModel; + + #[Length(min: 1, max: 120)] + public string $title; + + public ?Author $author = null; + + /** @var \App\Chapter[] */ + public array $chapters = []; +} +``` + +Thanks to the {b`Tempest\Database\IsDatabaseModel`} trait, you can directly interact with the database via the model class: + +```php +$book = Book::create( + title: 'Timeline Taxi', + author: $author, + chapters: [ + new Chapter(index: 1, contents: '…'), + new Chapter(index: 2, contents: '…'), + new Chapter(index: 3, contents: '…'), + ], +); + +$books = Book::select() + ->where('publishedAt > ?', new DateTimeImmutable()) + ->orderBy('title DESC') + ->limit(10) + ->with('author') + ->all(); + +$books[0]->chapters[2]->delete(); +``` + +## Migrations + +When you're persisting objects to the database, you'll need table to store its data in. A migration is a file instructing the framework how to manage that database schema. Tempest uses migrations to create and update databases across different environments. + +### Writing migrations + +Thanks to [discovery](../4-internals/02-discovery), `.sql` files and classes implementing the {b`Tempest\Database\DatabaseMigration`} interface are automatically registered as migrations, which means they can be stored anywhere. + +```php app/CreateBookTable.php +use Tempest\Database\DatabaseMigration; +use Tempest\Database\QueryStatement; +use Tempest\Database\QueryStatements\CreateTableStatement; +use Tempest\Database\QueryStatements\DropTableStatement; + +final class CreateBookTable implements DatabaseMigration +{ + public string $name = '2024-08-12_create_book_table'; + + public function up(): QueryStatement|null + { + return new CreateTableStatement('books') + ->primary() + ->text('title') + ->datetime('created_at') + ->datetime('published_at', nullable: true) + ->belongsTo('books.author_id', 'authors.id'); + } + + public function down(): QueryStatement|null + { + return new DropTableStatement('books'); + } +} +``` + +```sql app/2025-01-01_create_publisher_table.sql +CREATE TABLE Publisher +( + `id` INTEGER, + `name` TEXT NOT NULL +); +``` + +:::info +The file name of `{txt}.sql` migrations and the `{txt}{:hl-type:$name:}` property of `DatabaseMigration` classes are used to determine the order in which they are applied. A good practice is to use their creation date as a prefix. +::: + +Note that when using migration classes combined with query statements, Tempest will take care of the SQL dialect for you, there's support for MySQL, Postgresql, and SQLite. When using raw sql files, you'll have to pick a hard-coded SQL dialect, depending on your database requirements. + +### Applying migrations + +A few [console commands](../3-console/02-building-console-commands) are provided to work with migrations. They are used to apply, rollback, or erase and re-apply them. When deploying your application to production, you should use the `php tempest migrate:up` to apply the latest migrations. + +```sh +{:hl-comment:# Applies migrations that have not been run in the current environment:} +./tempest migrate:up + +{:hl-comment:# Rolls back every migration:} +./tempest migrate:down + +{:hl-comment:# Drops all tables and rerun migrate:up:} +./tempest migrate:fresh + +{:hl-comment:# Validates the integrity of migration files:} +./tempest migrate:validate +``` + +### Validating migrations + +By default, an integrity check is done before applying database migrations with the `migrate:up` and `migrate:fresh` commands. This validation works by comparing the current migration hash with the one stored in the `migrations` table, if it was already applied in your environment. + +If a migration file has been tampered with, the command will report it as a validation failure. Note that you may opt-out of this behavior by using the `--no-validate` argument. + +Additionally, you may use the `migrate:validate` command to validate the integrity of migrations at any point, in any environment: + +```sh +./tempest migrate:validate +``` + +:::tip +Only the actual SQL query of a migration, minified and stripped of comments, is hashed during validation. This means that code-style changes, such as indentation, formatting, and comments will not impact the validation process. +::: + +### Rehashing migrations + +You may use the `migrate:rehash` command to bypass migration integrity checks and update the hashes of migrations in the database. + +```sh +./tempest migrate:rehash +``` + +:::warning +Note that deliberately bypassing migration integrity checks may result in a broken database state. Only use this command when necessary if you are confident that your migration files are correct and consistent across environments. +::: + +## Database seeders + +Whenever you need to fill your database with dummy data, you can provide database seeders. These are classes that are used to fill your database with whatever data you want. To get started, you should implement the {`\Tempest\Database\DatabaseSeeder`} interface. + +```php +use Tempest\Database\DatabaseSeeder; +use UnitEnum; + +final class BookSeeder implements DatabaseSeeder +{ + public function run(null|string|UnitEnum $database): void + { + query(Book::class) + ->insert( + title: 'Timeline Taxi', + ) + ->onDatabase($database) + ->execute(); + } +} +``` + +Note how the `$database` property is passed into the `run()` method. In case a user has specified a database for this seeder to run on, this property will reflect that choice. + +Running database seeders can be done in two ways: either via the `database:seed` command, or via the `migrate:fresh` command. Not that `database:seed` will always append the seeded data on the existing database. + +```console +./tempest database:seed +./tempest migrate:fresh --seed +``` + +### Multiple seeders + +If you want to, you can create multiple seeder classes. Each seeder class could be used to bring the database into a specific state, or you could use multiple seeder classes to seed specific parts of your database. + +Whenever you have multiple seeder classes, Tempest will prompt you which ones to run: + +```console +./tempest database:seed + + │ Which seeders do you want to run? + │ / Filter... + │ → ⋅ Tests\Tempest\Fixtures\MailingSeeder + │ ⋅ Tests\Tempest\Fixtures\InvoiceSeeder +``` + +Both the `database:seed` and `migrate:fresh` commands also allow to pick one specific seeder or run all seeders automatically. + +```console +./tempest database:seed --all +./tempest database:seed --seeder="Tests\Tempest\Fixtures\MailingSeeder" + +./tempest migrate:fresh --seed --all +./tempest migrate:fresh --seeder="Tests\Tempest\Fixtures\MailingSeeder" +``` + +### Seeding on multiple databases + +Seeders have built-in support for multiple databases, which you can specify with the `--database` option. Continue reading to learn more about multiple databases. + +```console +./tempest database:seed --database="backup" +./tempest migrate:fresh --database="main" +``` + +## Multiple databases + +Tempest supports connecting to multiple databases at once. This can, for example, be useful to transfer data between databases or build multi-tenant systems. + +:::warning +Multiple database support on Windows is currently untested. We welcome anyone who wants to [contribute](https://github.com/tempestphp/tempest-framework/issues/1271). +::: + +### Connecting to multiple databases + +If you want to connect to multiple databases, you should make multiple database config files and attach a tag to each database config object: + +```php app/Config/database.config.php +use Tempest\Database\Config\SQLiteConfig; + +return new SQLiteConfig( + path: __DIR__ . '/../database.sqlite', + tag: 'main', +); +``` + +```php app/Config/database-backup.config.php +use Tempest\Database\Config\SQLiteConfig; + +return new SQLiteConfig( + path: __DIR__ . '/../database-backup.sqlite', + tag: 'backup', +); +``` + +When preferred, you can use a self-defined enum as the tag as well: + +```php app/Config/database-backup.config.php +use Tempest\Database\Config\SQLiteConfig; +use App\Database\DatabaseType; + +return new SQLiteConfig( + path: __DIR__ . '/../database-backup.sqlite', + tag: DatabaseType::BACKUP, +); +``` + +:::info +Note that the _default_ connection will always be the connection without a tag. +::: + +### Querying multiple databases + +With multiple databases configured, how do you actually use them when working with queries or models? There are several ways of doing so. The first approach is to manually inject separate database instances by using their tag: + +```php +use Tempest\Database\Database; +use Tempest\Container\Tag; +use App\Database\DatabaseType; +use function Tempest\Database\query; + +final class DatabaseBackupCommand +{ + public function __construct( + private Database $main, + #[Tag(DatabaseType::BACKUP)] private Database $backup, + ) {} + + public function __invoke(): void + { + $books = $this->main->fetch( + query(Book::class) + ->select() + ->where('published_at < ?', '2025-01-01') + ); + + $this->backup->execute( + query(Book::class)->insert(...$books) + ); + } +} +``` + +It might be quite cumbersome to write so much code everywhere if you're working with multiple databases though. That's why there's a shorthand available that doesn't require you to inject multiple database instances: + +```php +use App\Database\DatabaseType; +use function Tempest\Database\query; + +final class DatabaseBackupCommand +{ + public function __invoke(): void + { + $books = query(Book::class) + ->select() + ->where('published_at < ?', '2025-01-01') + ->onDatabase(DatabaseType::MAIN) + ->all(); + + query(Book::class) + ->insert(...$books) + ->onDatabase(DatabaseType::BACKUP) + ->execute(); + } +} +``` + +Note that the same is possible when using active-record style models: + +```php +use App\Database\DatabaseType; + +final class DatabaseBackupCommand +{ + public function __invoke(): void + { + $books = Book::select() + ->where('published_at < ?', '2025-01-01') + ->onDatabase(DatabaseType::MAIN) + ->all(); + + Book::insert(...$books) + ->onDatabase(DatabaseType::BACKUP) + ->execute(); + } +} +``` + +### Migrating multiple databases + +To run migrations on a specific database, you must specify the `database` flag to the migration command: + +```sh +./tempest migrate:up --database=main +./tempest migrate:down --database=backup +./tempest migrate:fresh --database=main +./tempest migrate:validate --database=backup +``` + +:::info +When no database is provided, the default database will be used, this is the database that doesn't have a specific tag attached to it. +::: + +### Database-specific migrations + +Sometimes you might only want to run specific migrations on specific databases. Any database migration class may implement the {b`Tempest\Database\ShouldMigrate`}, which adds a `shouldMigrate()` method to determine whether a migration should run or not, based on the database: + +```php +use Tempest\Database\Database; +use Tempest\Database\DatabaseMigration; +use Tempest\Database\ShouldMigrate; + +final class MigrationForBackup implements DatabaseMigration, ShouldMigrate +{ + public string $name = '…'; + + public function shouldMigrate(Database $database): bool + { + return $database->tag === 'backup'; + } + + public function up(): QueryStatement + { /* … */ } + + public function down(): QueryStatement + { /* … */ } +} +``` + +### Dynamic databases + +In systems with dynamic databases, like, for example, multi-tenant systems; you might not always have a hard-coded tag available to configure and resolve the right database. In those cases, it's trivial to add as many dynamic databases as you'd like: + +```php +final class ConnectTenant +{ + public function __invoke(string $tenantId): void + { + // Use any database config you'd like: + $this->container->config(new SQLiteConfig( + path: __DIR__ . "/tenant-{$tenantId}.sqlite", + tag: $tenantId, + )); + } +} +``` + +Furthermore, you can run migrations programmatically on such dynamically defined databases using the {`Tempest\Database\Migrations\MigrationManager`}: + +```php +use Tempest\Database\Migrations\MigrationManager; + +final class OnboardTenant +{ + public function __construct( + private MigrationManager $migrationManager, + ) {} + + public function __invoke(string $tenantId): void + { + $setupMigrations = [ + new CreateMigrationsTable(), + // … + ]; + + foreach ($setupMigrations as $migration) { + $this->migrationManager->onDatabase($tenantId)->executeUp($migration); + } + } +} +``` + +Finally, you should register your dynamic database connections as well within the entry points of your application. This could be done with [middleware](/main/essentials/routing#route-middleware), or with a [kernel event hook](/main/extra-topics/package-development#provider-classes); that's up to you: + +```php +use Tempest\Container\Container; +use Tempest\Router\HttpMiddleware; +use Tempest\Core\Priority; + +#[Priority(Priority::HIGHEST)] +final class ConnectTenantMiddleware implements HttpMiddleware +{ + public function __construct( + private Container $container, + ) {} + + public function __invoke(Request $request, HttpMiddlewareCallable $next): Response + { + $tenantId = // Resolve tenant ID from the request + + (new ConnectTennant)($tenantId); + + return $next($request); + } +} +``` diff --git a/docs/1-essentials/04-console-commands.md b/docs/1-essentials/04-console-commands.md new file mode 100644 index 000000000..93083e31c --- /dev/null +++ b/docs/1-essentials/04-console-commands.md @@ -0,0 +1,319 @@ +--- +title: Console commands +description: "Learn how to write console commands with a modern, minimal syntax. In Tempest, this is done using attributes, which are automatically discovered by the framework." +--- + +## Overview + +Tempest leverages [discovery](../4-internals/02-discovery.md) to find class methods tagged with the {b`#[Tempest\Console\ConsoleCommand]`} attribute. Such methods will automatically be available as console commands through the `./tempest` executable. + +Additionally, Tempest supports [console middleware](#middleware), which makes it easier to build some console features. + +## Creating console commands + +A console command is defined by adding the {b`#[Tempest\Console\ConsoleCommand]`} attribute to any class method. Usually, this is done in a dedicated command class, but it can be any method in any class. + +```php +final readonly class TrackOperatingAircraft +{ + #[ConsoleCommand(name: 'aircraft:track')] + public function __invoke(): void + { + // … + } +} +``` + +The command will be named after the class name and the method name. If you prefer, you may add a `name` argument to the {b`#[Tempest\Console\ConsoleCommand]`} attribute to give a dedicated name to the command. + +You may learn more about [configuring commands](#configuring-commands) in the dedicated section. + +### Writing to the output + +You may use the {`Tempest\Console\Console`} interface to write to the output. You can do this by injecting it into your command class, or by using the {`Tempest\Console\HasConsole`} trait, which provides a `$console` property. + +The console methods are documented, but you might use the following ones most often: + +```php +// Writes a line to the output. +$this->console->writeln('Hello from Tempest!'); + + // Writes an informational, error, or warning message. +$this->console->info('This is an informational message.'); +$this->console->error('This is an error message.'); +$this->console->warning('This is a warning.'); + +// Prompts for user input. Supports validation and multiple choices. +$this->console->ask('What should be the email?', validation: [new Email()]); + +// Executes and reports the progress of a closure. +$this->console->task('Syncing...', $this->synchronize(...)); +``` + +### Specifying an exit code + +Optionally, console may return an exit code. By default, Tempest will infer the correct exit code, depending on whether the command was successful or not. + +If you want more control over which exit code is returned, you may return an integer between 0 and 255. For convenience, Tempest comes with an {`Tempest\Console\ExitCode`} enumeration that has a handful of predefined exit codes, which are generally accepted to be standard. + +```php +use Tempest\Console\ExitCode; + +public function __invoke(): ExitCode +{ + if (! $this->hasBeenSetup()) { + return ExitCode::ERROR; + } + + // … + + return ExitCode::SUCCESS; +} +``` + +## Command arguments + +The command definition is inferred by the method's parameters. This way, there is no need to remember a framework-specific syntax—this is simple, modern PHP. + +```php +final readonly class TrackOperatingAircraft +{ + #[ConsoleCommand('aircraft:track')] + public function __invoke(AircraftType $type, ?int $radius = null): void + { + // … + } +} +``` + +All built-in types are supported, including enums. When a parameter is nullable, it is also optional when invoking the console command. + +### Negating boolean arguments + +You may negate boolean flags by prefixing them with `--no`. + +For instance, if the command has a `$validate` parameter with a default value of `true`, using the `--no-validate` flag would set the value of `$validate` to `false`. + +### Adding a description or an alias + +You may provide the {b`#[Tempest\Console\ConsoleArgument]`} to any argument of the method definition. This may be used to describe the argument, change its name or specify an alias. + +```php +final readonly class TrackOperatingAircraft +{ + #[ConsoleCommand( + name: 'aircraft:track', + description: 'Updates operating aircraft in the database' + )] + public function __invoke( + #[ConsoleArgument(description: 'Specifies the type of aircraft to track')] + AircraftType $type, + #[ConsoleArgument( + description: 'Specifies the maximum radius around HQ to track aircraft in', + aliases: ['r'] + )] + ?int $radius = null + ): void + { + // … + } +} +``` + +Argument description are visible when using the `--help` flag during command invokation. + +```console +./tempest aircraft:track --help + +

    // AIRCRAFT:TRACK

    +Updates operating aircraft in the database + +

    // USAGE

    +aircraft:track pc12|pc24}> [radius=null] + +type +Specifies the type of aircraft to track + +radius (r) +Specifies the maximum radius around HQ to track aircraft in +``` + +## Configuring commands + +The {b`#[Tempest\Console\ConsoleCommand]`} attribute accepts a few arguments that may provide more context to the user or affect its functionality. + +For instance, the `middleware` argument accepts a list of [middleware classes](#middleware) for this command. + +### Adding a description + +You may use the `description` argument on the {b`#[Tempest\Console\ConsoleCommand]`} attribute to provide context to users regarding the functionality of the command. + +This description is shown when listing console commands or when calling it with the `--help` argument. + +```php +final readonly class TrackOperatingAircraft +{ + #[ConsoleCommand(description: 'Updates operating aircraft in the database')] + public function __invoke(): void + { + // … + } +} +``` + +### Hiding the command + +A command may be completely hidden from the command list by setting the `hidden` argument to `true`. The command will remain invokable, but will not be visible to the user when listing commands. + +```php +final readonly class TrackOperatingAircraft +{ + #[ConsoleCommand(hidden: true)] + public function __invoke(): void + { + // … + } +} +``` + +### Specifying a name + +The `name` argument of the {b`#[Tempest\Console\ConsoleCommand]`} attribute allows for configuring the command name. This is the name used for the command invokation, and the name that is displayed when listing all commands. + +```php +final readonly class TrackOperatingAircraft +{ + #[ConsoleCommand('aircraft:track')] + public function __invoke(): void + { + // … + } +} +``` + +### Specifying aliases + +When a command is used a lot, you may add aliases instead of shortening its name. To do this, use the `aliases` argument of the {b`#[Tempest\Console\ConsoleCommand]`} attribute. + +```php +final readonly class TrackOperatingAircraft +{ + #[ConsoleCommand('aircraft:track', aliases: ['track'])] + public function __invoke(AircraftType $type): void + { + // … + } +} +``` + +You may then call the command by using this alias. + +### Preventing usage in production + +Some commands are dangerous to use in a non-local environment. You may add the {b`Tempest\Console\Middleware\CautionMiddleware`} to a command to prevent it from being invoked in production. When this happens, the user will be alerted and provided with the choice to continue or abort the command execution. + +```php +final readonly class SynchronizeAircraft +{ + #[ConsoleCommand('aircraft:sync', middleware: [CautionMiddleware::class])] + public function __invoke(): void + { + // … + } +} +``` + +## Middleware + +Console middleware can be applied globally or on a per-command basis. Global console middleware will be discovered and applied automatically, by priority order. + +### Building your own middleware + +You may implement the {`Tempest\Console\ConsoleMiddleware`} interface to build a console middleware. + +```php app/InspireMiddleware.php +use Tempest\Console\ConsoleMiddleware; +use Tempest\Console\ConsoleMiddlewareCallable; + +final readonly class InspireMiddleware implements ConsoleMiddleware +{ + public function __construct( + private InspirationService $inspiration, + private Console $console, + ) {} + + public function __invoke(Invocation $invocation, ConsoleMiddlewareCallable $next): ExitCode|int + { + if ($invocation->argumentBag->get('inspire')) { + $this->console->writeln($this->inspiration->random()); + } + + return $next($invocation); + } +} +``` + +Middleware classes will be autowired by the container, so you can use the constructor to inject any dependency you'd like. The {b`Tempest\Console\Initializers\Invocation`} object contains everything you need about the context for the current console command invocation: + +- `{php}$invocation->argumentBag` contains the argument bag with all the input provided by the user. +- `{php}$invocation->consoleCommand` an instance of the {b`#[Tempest\Console\ConsoleCommand]`} attribute for the matched console command. This property will be `null` if you're not using {b`Tempest\Console\Middleware\ResolveOrRescueMiddleware`} or if your middleware runs before it. + +#### Middleware priority + +All console middleware classes get sorted based on their priority. By default, each middleware gets the normal priority, but you can override it using the {b`#[Tempest\Core\Priority]`} attribute: + +```php app/InspireMiddleware.php +use Tempest\Core\Priority; + +#[Priority(Priority::HIGH)] +final readonly class InspireMiddleware implements ConsoleMiddleware +{ /* … */ } +``` + +Note that priority is defined using an integer. However, the {b`Tempest\Core\Priority`} class provides a few constant with predefined priorities: `Priority::FRAMEWORK`, `Priority::HIGHEST`, `Priority::HIGH`, `Priority::NORMAL`, `Priority::LOW`, `Priority::LOWEST`. + +#### Middleware discovery + +Global console middleware classes are discovered and sorted based on their priority. You can make a middleware class non-global by using the {b`#[Tempest\Discovery\SkipDiscovery]`} attribute: + +```php +use Tempest\Discovery\SkipDiscovery; + +#[SkipDiscovery] +final readonly class InspireMiddleware implements ConsoleMiddleware +{ /* … */ } +``` + +### Built-in middleware + +Tempest provides a few built-in middleware that you may use on your console commands. Some of these middleware are used internally on some commands, and some of them are used on all commands. + +- The {b`Tempest\Console\Middleware\ForceMiddleware`} adds the `--force` flag for skipping `{php}$console->confirm()` calls. +- The {b`Tempest\Console\Middleware\CautionMiddleware`} middleware [prevents usage of commands in production](#preventing-usage-in-production). +- The {b`Tempest\Console\Middleware\OverviewMiddleware`} is responsible from listing all commands when none is provided. +- The {b`Tempest\Console\Middleware\ResolveOrRescueMiddleware`} middleware provides a list of similar commands when an unknown command is invoked. +- The {b`Tempest\Console\Middleware\HelpMiddleware`} middleware provides help when the `--help` flag is used. +- The {b`Tempest\Console\Middleware\ConsoleExceptionMiddleware`} middleware catches and properly render console exceptions. + +## Scheduling + +Console commands—or any public class method—may be scheduled by using the {b`#[Tempest\Console\Schedule]`} attribute, which accepts an {b`Tempest\Console\Scheduler\Interval`} or {b`Tempest\Console\Scheduler\Every`} value. Methods with this attributes are automatically [discovered](../4-internals/02-discovery.md), so there is nothing more to add. + +You may read more on the [dedicated chapter](../2-features/11-scheduling.md). + +## Testing + +Tempest provides a console command testing utility accessible through the `console` property of the [`IntegrationTest`](https://github.com/tempestphp/tempest-framework/blob/main/src/Tempest/Framework/Testing/IntegrationTest.php) test case. You may learn more about testing in the [dedicated chapter](./07-testing.md). + +```php tests/ExportUsersCommandTest.php +$this->console + ->call(ExportUsersCommand::class) + ->assertSuccess() + ->assertSee('12 users exported'); + +$this->console + ->call(WipeDatabaseCommand::class) + ->assertSee('caution') + ->submit() + ->assertSuccess(); +``` diff --git a/docs/1-essentials/05-container.md b/docs/1-essentials/05-container.md new file mode 100644 index 000000000..7e49e0aa8 --- /dev/null +++ b/docs/1-essentials/05-container.md @@ -0,0 +1,406 @@ +--- +title: Container +description: "Learn how Tempest's container works, how to inject and resolve dependencies, and how to implement initialization logic for your service classes when they need it." +--- + +## Overview + +A dependency container is a system that manages the creation and resolution of objects within an application. Instead of manually instantiating dependencies, classes declare what they need, and the container provides them automatically. + +Tempest has a dependency container capable of resolving dependencies without any configuration. Most features are built upon this concept, from controllers to console commands, through event handlers and the command bus. + +## Injecting dependencies + +The constructors of classes resolved by the container may be any class or interface associated with a [dependency initializer](#dependency-initializers). Similarly, invoked methods such as [event handlers](../2-features/08-events.md), [console commands](../3-console/02-building-console-commands) and invokable classes may also be called directly from the container. + +```php app/Aircraft/AircraftService.php +use App\Aircraft\ExternalAircraftProvider; +use App\Aircraft\AircraftRepository; +use Tempest\Console\ConsoleCommand; + +final readonly class AircraftService +{ + public function __construct( + private ExternalAircraftProvider $externalAircraftProvider, + private AircraftRepository $repository, + ) {} + + #[ConsoleCommand] + public function synchronize(): void + { + // … + } +} +``` + +### Invoking a method or function + +If you have access to the container instance, you may call its `{php}invoke()` method to call another method, function or invokable class, resolving its dependencies along the way. + +Using named arguments, it is also possible to manually specify parameters on the invoked method: + +```php +$this->container->invoke(TrackOperatingAircraft::class, type: AircraftType::PC12); +``` + +The `{php}\Tempest\invoke()` function serves the same purpose when the container is not directly accessible. + +### Locating a dependency + +There are situations where it may not be possible to inject a dependency on a constructor. To work around this, Tempest provides the `{php}\Tempest\get()` function, which can resolve an object from the container. + +```php +use function Tempest\get; + +$config = get(AppConfig::class); +``` + +:::warning +Resolving services this way should only be used as a last resort. If you are interested in knowing why, you may read more about service location in this [blog post](https://stitcher.io/blog/service-locator-anti-pattern). +::: + +## Dependency initializers + +When you need fine-grained control over how a dependency is constructed instead of relying on Tempest's autowiring capabilities, you can use initializer classes. + +Initializers are classes that know how to construct a specific class or interface. Whenever that class or interface is requested from the container, Tempest will use its corresponding initializer to construct it. + +### Implementing an initializer + +Initializers are classes that implement the {`Tempest\Container\Initializer`} interface. The `initialize()` method receives the container as its only parameter, and returns an instanciated object. + +**Most importantly**, Tempest knows which object this initializer is tied to thanks to the return type of the `initialize()` method, which needs to be typed. + +```php app/MarkdownInitializer.php +use Tempest\Container\Container; +use Tempest\Container\Initializer; + +final readonly class MarkdownInitializer implements Initializer +{ + public function initialize(Container $container): MarkdownConverter + { + $environment = new Environment(); + $highlighter = new Highlighter(new CssTheme()); + + $highlighter + ->addLanguage(new TempestViewLanguage()) + ->addLanguage(new TempestConsoleWebLanguage()) + ->addLanguage(new ExtendedJsonLanguage()); + + $environment + ->addExtension(new CommonMarkCoreExtension()) + ->addExtension(new FrontMatterExtension()) + ->addRenderer(FencedCode::class, new CodeBlockRenderer($highlighter)) + ->addRenderer(Code::class, new InlineCodeBlockRenderer($highlighter)); + + return new MarkdownConverter($environment); + } +} +``` + +The above example is an initializer for a `MarkdownConverter` class. It will set up a markdown converter, configure its extensions, and finally return the object. Whenever `MarkdownConverter` is requested via the container, this initializer class will be used to construct it. + +### Matching multiple classes or interfaces + +The container may match several classes to a single initializer if it has a union return type. + +```php app/MarkdownInitializer.php +use Tempest\Container\Container; +use Tempest\Container\Initializer; + +final readonly class MarkdownInitializer implements Initializer +{ + public function initialize(Container $container): MarkdownConverter|Markdown + { + // … + } +} +``` + +### Dynamically matching classes or interfaces + +While initializers are capable of resolving almost all situations, there are times where the return type of `initialize` is not enough and more flexibility is needed. + +Let's take use the concept of route model binding as an example. A controller might accept an instance of a model as its parameters: + +```php app/BookController.php +use Tempest\Router\Get; +use Tempest\Http\Response; + +final readonly class BookController +{ + #[Get('/books/{book}')] + public function show(Book $book): Response { /* … */ } +} +``` + +Since `$book` isn't a scalar value, Tempest will try to resolve `{php}Book` from the container whenever this controller action is invoked. This means we need an initializer that's able to match the `Book` model: + +```php app/BookInitializer.php +use Tempest\Container\Container; +use Tempest\Container\Initializer; + +final class BookInitializer implements Initializer +{ + public function initialize(Container $container): Book + { + // … + } +} +``` + +While this approach works, it would be very inconvenient to create an initializer for every model class. Furthermore, we want route binding to be provided by the framework, so we need a more generic approach. + +The {`Tempest\Container\DynamicInitializer`} interface provides a `canInitialize` method, in which the logic for matching a class may be implemented: + +```php app/RouteBindingInitializer.php +use Tempest\Container\Container; +use Tempest\Container\DynamicInitializer; + +final class RouteBindingInitializer implements DynamicInitializer +{ + public function canInitialize(string $className): bool + { + return is_a($className, Model::class, true); + } + + public function initialize(string $className, Container $container): object + { + // … + } +} +``` + +## Autowired dependencies + +When you need to assign a default implementation to an interface without any specific instantiation steps, creating an initializer class for a single line of code might feel excessive. + +```php app/AircraftServiceInitializer.php +use Tempest\Container\Container; +use Tempest\Container\Initializer; + +final readonly class AircraftServiceInitializer implements Initializer +{ + public function initialize(Container $container): AircraftServiceInterface + { + return new AircraftService(); + } +} +``` + +For simple one-to-one mappings, you can skip the initializer class, instead using the `#[Autowire]` attribute on the default implementation. Tempest will discover this, and link that class to the interface it implements: + +```php app/AircraftService.php +use Tempest\Container\Autowire; + +#[Autowire] +final readonly class AircraftService implements AircraftServiceInterface +{ + // … +} +``` + +## Singletons + +If you need to register a class as a singleton in the container, you can use the `#[Singleton]` attribute. Any class can have this attribute: + +```php app/Services/AircraftService/Client.php +use Tempest\Container\Singleton; +use Tempest\HttpClient\HttpClient; + +#[Singleton] +final readonly class Client +{ + public function __construct( + private HttpClient $http, + ) {} + + public function fetch(Icao $icao): Aircraft + { + // … + } +} +``` + +Furthermore, an initializer method can be annotated as a `#[Singleton]`, meaning its return object will only ever be resolved once: + +```php app/MarkdownInitializer.php +use Tempest\Console\ConsoleCommand; +use Tempest\Container\Initializer; +use Tempest\Container\Singleton; + +final readonly class MarkdownInitializer implements Initializer +{ + #[Singleton] + public function initialize(Container $container): MarkdownConverter|Markdown + { + // … + } +} +``` + +### Tagged singletons + +In some cases, you want more control over singleton definitions. + +Let's say you want an instance of `{php}\Tempest\Highlight\Highlighter` that would be configured for web highlighting, and one that would be configured CLI highlighting. In this situation, you can differenciate them using the `tag` parameter of the `#[Singleton]` attribute: + +```php app/WebHighlighterInitializer.php +use Tempest\Container\Container; +use Tempest\Container\Initializer; +use Tempest\Container\Singleton; + +final readonly class WebHighlighterInitializer implements Initializer +{ + #[Singleton(tag: 'web')] + public function initialize(Container $container): Highlighter + { + return new Highlighter(new CssTheme()); + } +} +``` + +Retrieving this specific instance from the container may be done by using the `{php}#[Tag]` attribute during autowiring: + +```php app/HttpExceptionHandler.php +use Tempest\Container\Tag; + +class HttpExceptionHandler implements ExceptionHandler +{ + public function __construct( + #[Tag('web')] + private Highlighter $highlighter, + ) {} +} +``` + +If you have a container instance, you may also get it directly using the `tag` argument: + +```php +$container->get(Highlighter::class, tag: 'cli'); +``` + +:::info +[This blog post](https://stitcher.io/blog/tagged-singletons), by {gh:brendt}, provides in-depth explanations about tagged singletons. +::: + +### Dynamic tags + +Some components implement the {`Tempest\Container\HasTag`} interface, which requires a `tag` property. Singletons using this interface are tagged by the `tag` property, essentially providing the ability to have dynamic tags. + +This is specifically useful to get multiple instances of the same configuration. This is how [multiple database connections support](../1-essentials/03-database.md#using-multiple-connections) is implemented. + +## Built-in types dependencies + +Besides being able to depend on objects, sometimes you'd want to depend on built-in types like `string`, `int` or more often `array`. It is possible to depend on these built-in types, but these cannot be autowired and must be initialized through a [tagged singleton](#tagged-singletons). + +For example if we want to group a specific set of validators together as a tagged collection, you can initialize them in a tagged singleton initializer like so: + +```php +// app/BookValidatorsInitializer.php + +use Tempest\Container\Container; +use Tempest\Container\Initializer; + +final readonly class BookValidatorsInitializer implements Initializer +{ + #[Singleton(tag: 'book-validators')] + public function initialize(Container $container): array + { + return [ + $container->get(HeaderValidator::class), + $container->get(BodyValidator::class), + $container->get(FooterValidator::class), + ]; + } +} +``` + +Now you can use this group of validators as a normal tagged value in your container: + +```php +// app/BookController.php + +use Tempest\Container\Tag; + +final readonly class BookController +{ + public function __constructor( + #[Tag('book-validators')] private readonly array $contentValidators, + ) { /* … */ } +} +``` + +## Injected properties + +While constructor injection is almost always the preferred way to go, Tempest also offers the ability to inject values straight into properties, without them being requested by the constructor. + +You may mark any property—public, protected, or private—with the `#[Inject]` attribute. Whenever a class instance is resolved via the container, its properties marked for injection will be provided the right value. + +```php Tempest/Console/src/HasConsole.php +use Tempest\Container\Inject; + +trait HasConsole +{ + #[Inject] + private Console $console; + + // … +} +``` + +Keep in mind that injected properties are a form of service location. While it's recommended to rely on constructor injection by default, injected properties may offer flexibility when using traits without having to claim the constructor within that trait. + +For example, without injected properties, the above example would have to define a constructor within the trait to inject the `Console` dependency: + +```php +trait HasConsole +{ + public function __construct( + private readonly Console $console, + ) {} + + // … +} +``` + +On its own, that isn't a problem, but it causes some usability issues when using this trait in classes that require other dependencies as well: + +```php +use Tempest\Console\HasConsole; + +class MyCommand +{ + use HasConsole; + + public function __construct( + private BlogPostRepository $repository, + + // The `HasConsole` trait breaks if you didn't remember to explicitly inject it here + private Console $console, + ) {} + + // … +} +``` + +For these edge cases, it's nicer to make the trait self-contained without having to rely on constructor injection. That's why injected properties are supported. + +## Proxy loading + +The container supports lazy loading of dependencies using the `#[Proxy]` attribute. Using this attribute on a property (that has `#[Inject]`) or a constructor parameter +will allow the container to instead inject a lazy proxy. +Since lazy proxies are transparent to the consumer you do not need to change anything else in your code. +The primary use case for this are heavy dependencies that may or may not be used. + +```php app/BookController.php +use Tempest\Container\Proxy; + +final readonly class BookController +{ + public function __construct( + #[Proxy] + private VerySlowClass $verySlowClass + ) { /* … */ } +} +``` diff --git a/docs/1-essentials/06-configuration.md b/docs/1-essentials/06-configuration.md new file mode 100644 index 000000000..6d7cf10d7 --- /dev/null +++ b/docs/1-essentials/06-configuration.md @@ -0,0 +1,146 @@ +--- +title: Configuration +description: "Tempest takes a unique approach at configuration, providing an excellent developer experience due to its inherent support from code editors." +--- + +## Overview + +Within Tempest, configuration is represented by objects. This allows code editors to provide static insights and autocompletion during edition, resulting in an unmatched developer experience. + +Even though the framework is designed to use as little configuration as possible, many configuration classes are available. When fine-grained control over a specific part of the framework is needed, the default configuration can be overwritten. + +## Configuration files + +Files ending with `*.config.php` are recognized by Tempest's [discovery](../4-internals/02-discovery) as configuration objects, and will be registered as [singletons](./01-container#singletons) in the container. + +```php app/postgres.config.php +use Tempest\Database\Config\PostgresConfig; +use function Tempest\env; + +return new PostgresConfig( + host: env('DB_HOST'), + port: env('DB_PORT'), + username: env('DB_USERNAME'), + password: env('DB_PASSWORD'), + database: env('DB_DATABASE'), +); +``` + +The configuration object above instructs Tempest to use PostgreSQL as its database, replacing the framework's default database, SQLite. + +## Accessing configuration objects + +To access a configuration object, you may inject it from the container like any other dependency. + +```php +use Tempest\Core\AppConfig; + +final readonly class HomeController +{ + public function __construct( + private AppConfig $config, + ) {} + + #[Get('/')] + public function __invoke(): View + { + return view('home.view.php', environment: $this->config->environment); + } +} +``` + +## Updating configuration objects + +To update a property in a configuration object, you may simply assign a new value. Due to the object being a singleton, the modification will be persisted throught the rest of the application's lifecycle. + +```php +use Tempest\Support\Random; +use Tempest\Vite\ViteConfig; + +$this->viteConfig->nonce = Random\secure_string(length: 40); +``` + +Alternatively, you may completely override the configuration instance by calling the `config()` method of the container, registering the new object as a singleton. + +```php +$this->container->config(new SQLiteConfig( + path: root_path('database.sqlite'), +)); +``` + +## Creating your own configuration + +As your application grows, you may need to create your own configuration objects. Such a use case could be an integration with Slack, where an API token and an application ID would be required. + +You may first create a class representing the configuration needed for your feature. It can have default values for its properties, and even methods if needed. + +```php app/Slack/SlackConfig.php +final class SlackConfig +{ + public function __construct( + public string $token, + public string $baseUrl, + public string $applicationId, + public string $userAgent, + ) {} +} +``` + +The next step is to register this configuration object in the container. This can be done by creating a `slack.config.php` file, which will be discovered by Tempest and registered as a [singleton](./01-container#singletons). + +```php app/Slack/slack.config.php +use function Tempest\env; + +return new SlackConfig( + token: env('SLACK_API_TOKEN'), + baseUrl: env('SLACK_BASE_URL', default: 'https://slack.com/api'), + applicationId: env('SLACK_APP_ID'), + userAgent: env('USER_AGENT'), +); +``` + +You may now inject the `SlackConfig` class into a service, a controller, an action, or anything that can be resolved by the container. + +```php app/Slack/SlackConnector.php +final class SlackConnector extends HttpConnector +{ + public function __construct( + private readonly SlackConfig $slackConfig, + ) { + } + + public function resolveBaseUrl(): string + { + return $this->slackConfig->baseUrl; + } + + // ... +} +``` + +## Per-environment configuration + +Whenever possible, you should have a single configuration file per feature. You may use the `Tempest\env()` function inside that file to reference credentials and environment-specific values. + +However, it's sometimes needed to have completely different configurations in development and in production. For instance, you may use S3 for your [storage](../2-features/05-file-storage.md) in production, but use the local filesystem during development. + +When this happens, you may create environment-specific configuration files by using the `..config.php` suffix. For instance, a production-only configuration file could be `storage.prod.config.php`: + +```php app/storage.prod.config.php +return new S3StorageConfig( + bucket: env('S3_BUCKET'), + region: env('S3_REGION'), + accessKeyId: env('S3_ACCESS_KEY_ID'), + secretAccessKey: env('S3_SECRET_ACCESS_KEY'), +); +``` + +## Disabling the configuration cache + +During development, Tempest will discover configuration files every time the framework is booted. In a production environment, configuration files are automatically cached. + +You may override this behavior by setting the `{txt}{:hl-property:CONFIG_CACHE:}` environment variable to `true`. + +```env .env +{:hl-property:CONFIG_CACHE:}={:hl-keyword:true:} +``` diff --git a/docs/1-essentials/07-testing.md b/docs/1-essentials/07-testing.md new file mode 100644 index 000000000..06e40e500 --- /dev/null +++ b/docs/1-essentials/07-testing.md @@ -0,0 +1,99 @@ +--- +title: Testing +description: "Tempest is built with testing in mind. It ships with convenient utilities that make it easy to test application code without boilerplate." +keywords: ["phpunit", "pest"] +--- + +## Overview + +Tempest uses [PHPUnit](https://phpunit.de) for testing and provides an integration through the [`Tempest\Framework\Testing\IntegrationTest`](https://github.com/tempestphp/tempest-framework/blob/main/src/Tempest/Framework/Testing/IntegrationTest.php) test case. This class boots the framework with configuration suitable for testing, and provides access to multiple utilities. + +Testing utilities specific to components are documented in their respective chapters. For instance, testing the router is described in the [routing documentation](./01-routing.md#testing). + +## Running tests + +If you created a Tempest application through the [recommended installation process](../0-getting-started/02-installation.md), you already have access to `tests/IntegrationTestCase`, which your application tests can inherit from. + +In this case, you may use the `composer phpunit` command to run your test suite. + +```sh +composer phpunit +``` + +## Creating new test files + +By default, PHPUnit is configured to look for test files that end in `*Test.php` in the root `tests` directory. You may create a such a file and make it extend `IntegrationTestCase`. + +```php tests/HomeControllerTest.php +use Tests\IntegrationTestCase; + +final class HomeControllerTest extends IntegrationTestCase +{ + public function test_index(): void + { + $this->http + ->get('/') + ->assertOk(); + } +} +``` + +## Test-specific discovery locations + +Tempest does not discover files outside of the namespaces defined in the `require` object of `composer.json`. If you need Tempest to discover test-specific fixture files, you may specify paths using the `discoveryLocations` property of the provided `IntegrationTestCase` class. + +For instance, you may create a `tests/config` directory that contains test-specific configuration files, and instruct Tempest to discover them: + +```php tests/IntegrationTestCase.php +use Tempest\Core\DiscoveryLocation; + +final class IntegrationTestCase extends TestCase +{ + protected string $root = __DIR__ . '/../'; + + protected function setUp(): void + { + $this->discoveryLocations = [ + new DiscoveryLocation(namespace: 'Tests\\Config', path: __DIR__ . '/config'), + ]; + + parent::setUp(); + } +} +``` + +## Changing the location of tests + +The `phpunit.xml` file contains a `{html}` element that configures the directory in which PHPUnit looks for test files. This may be changed to follow any rule of your convenience. + +For instance, you may colocate test files and their corresponding class by changing the `{html}suffix` attribute in `phpunit.xml` to the following: + +```diff phpunit.xml + + +- ./tests ++ ./app + + +``` + +## Using Pest as a test runner + +[Pest](https://pestphp.com/) is a test runner built on top of PHPUnit. It provides a functional way of writing tests similar to JavaScript testing frameworks like [Vitest](https://vitest.dev/), and features an elegant console reporter. + +Pest is framework-agnostic, so you may use it in place of PHPUnit if that is your preference. The [installation process](https://pestphp.com/docs/installation) consists of removing the dependency on `phpunit/phpunit` in favor of `pestphp/pest`. + +```sh +{:hl-type:composer:} remove {:hl-keyword:phpunit/phpunit:} +{:hl-type:composer:} require {:hl-keyword:pestphp/pest:} --dev --with-all-dependencies +``` + +The next step is to create a `tests/Pest.php` file, which will instruct Pest how to run tests. You may read more about this file in the [dedicated documentation](https://pestphp.com/docs/configuring-tests). + +```php tests/Pest.php +pest() + ->extend(Tests\IntegrationTestCase::class) + ->in(__DIR__); +``` + +You may now run `./vendor/bin/pest` to run your test suite. You might also want to replace the `phpunit` script in `composer.json` by one that uses Pest. diff --git a/docs/1-essentials/08-primitive-utilities.md b/docs/1-essentials/08-primitive-utilities.md new file mode 100644 index 000000000..261fc6f59 --- /dev/null +++ b/docs/1-essentials/08-primitive-utilities.md @@ -0,0 +1,91 @@ +--- +title: Primitive utilities +description: "Working with strings and arrays in PHP is notoriously hard due to the lack of a standard library. Tempest comes with a bunch of utilities to improve the experience in this area." +--- + +## Overview + +Tempest provides a set of utilities that make working with primitive values easier. It provides an object-oriented API for handling strings and arrays, along with many namespaced functions to work with arithmetic operations, regular expressions, random values, pluralization, filesystem paths and more. + +## Namespaced functions + +Most utilities provided by Tempest have a function-based implementation under the [`Tempest\Support`](https://github.com/tempestphp/tempest-framework/tree/main/packages/support/src) namespace. You may look at what is available on GitHub: + +- [Regular expressions](https://github.com/tempestphp/tempest-framework/blob/main/packages/support/src/Regex/functions.php) +- [Arithmetic operations](https://github.com/tempestphp/tempest-framework/blob/main/packages/support/src/Math/functions.php) +- [Filesystem operations](https://github.com/tempestphp/tempest-framework/blob/main/packages/support/src/Filesystem/functions.php) +- [Filesystem paths](https://github.com/tempestphp/tempest-framework/blob/main/packages/support/src/Path/functions.php) +- [Json manipulation](https://github.com/tempestphp/tempest-framework/blob/main/packages/support/src/Json/functions.php) +- [Random values](https://github.com/tempestphp/tempest-framework/blob/main/packages/support/src/Random/functions.php) +- [Pluralization](https://github.com/tempestphp/tempest-framework/blob/main/packages/support/src/Language/functions.php) +- [PHP namespaces](https://github.com/tempestphp/tempest-framework/blob/main/packages/support/src/Namespace/functions.php) + +Tempest also provids the {`Tempest\Support\IsEnumHelper`} trait to work with enumerations, since a functional API is not useful in this case. + +## String utilities + +Tempest provides string utilities through [namespaced functions](https://github.com/tempestphp/tempest-framework/blob/main/src/Tempest/Support/src/Str/functions.php) or a fluent, object-oriented API, which comes in an immutable and a mutable flavor. + +Providing a string value, you may create an instance of {`\Tempest\Support\Str\ImmutableString`} or {`\Tempest\Support\Str\MutableString`}: + +```php +use Tempest\Support\Str; +use Tempest\Support\Str\ImmutableString; + +// Functional API +$title = Str\to_sentence_case($title); + +// Object-oriented API +$slug = new ImmutableString('/blog/01-chasing-bugs-down-the-rabbit-hole/') + ->stripEnd('/') + ->afterLast('/') + ->replaceRegex('/\d+-/', '') + ->slug() + ->toString(); +``` + +Note that you may use the `str()` function as a shorthand to create an {b`\Tempest\Support\Str\ImmutableString`} instance. + +## Array utilities + +Tempest provides array utilities through [namespaced functions](https://github.com/tempestphp/tempest-framework/blob/main/src/Tempest/Support/src/Arr/functions.php) or a fluent, object-oriented API, which comes in an immutable and a mutable flavor. + +Providing an iterable value, you may create an instance of {`\Tempest\Support\Arr\ImmutableArray`} or {`\Tempest\Support\Arr\MutableArray`}: + +```php +use Tempest\Support\Arr; +use Tempest\Support\Arr\ImmutableArray; + +// Functional API +$first = Arr\first($collection); + +// Object-oriented API +$items = new ImmutableArray(glob(__DIR__ . '/content/*.md')) + ->reverse() + ->map(function (string $path) { + // … + }) + ->mapTo(BlogPost::class); +``` + +Note that you may use the `arr()` function as a shorthand to create an {b`\Tempest\Support\Arr\ImmutableArray`} instance. + +## Recommendations + +We recommend working with primitive utilities when possible instead of using PHP's built-in methods. For instance, you may read a file by using `Filesystem\read_file`: + +```php +use Tempest\Support\Filesystem; + +$contents = Filesystem\read_file(__DIR__ . '/content.md'); +``` + +Using this function covers more edge cases and throws clear exceptions that are easier to catch. Similarly, it may not be useful to always reach for the object-oriented array and string helpers. Sometimes, you may simply use a single function: + +```php +use Tempest\Support\Str; +use function Tempest\Support\str; + +{- $title = str('My title')->title()->toString(); -} +{+ $title = Str\to_title_case('My title'); +} +``` diff --git a/docs/2-features/01-mapper.md b/docs/2-features/01-mapper.md new file mode 100644 index 000000000..035a5709f --- /dev/null +++ b/docs/2-features/01-mapper.md @@ -0,0 +1,238 @@ +--- +title: Mapper +description: "The mapper component is capable of mapping data to objects and the other way around. It is one of Tempest's most powerful tools." +--- + +## Overview + +Tempest comes with a mapper component that can be used to map all sorts of data to objects and back. For instance, it may map the request data to a request class, or the result of an SQL query to a model class. + +This component is used internally to handle persistence between models and the database, map PSR objects to internal requests, map request data to objects, and more. It is flexible enough to be used as-is, or you can build your own mappers. + +## Mapping data + +You may map data from a source to a target using the `map()` function. This function accepts the source data you want to map as its sole parameter, and returns a mapper instance. + +Calling the `to()` method on this instance will return a new instance of the target class, populated with the mapped data: + +```php +use function Tempest\map; + +$book = map($rawBookAsJson)->to(Book::class); +``` + +### Mapping to collections + +When the source data is an array, you may instruct the mapper to map each item of the collection to an instance of the target class by calling the `collection()` method. + +```php +use function Tempest\map; + +$books = map($rawBooksAsJson) + ->collection() + ->to(Book::class); +``` + +### Choosing specific mappers + +By default, Tempest finds out which mapper to use based on the source and target types. However, you can also specify which mapper to use by calling the `with()` method on the mapper instance. This method accepts one or multiple mapper class names, which will be used for the mapping. + +```php +$psrRequest = map($request) + ->with(RequestToPsrRequestMapper::class) + ->do(); +``` + +Alternatively, you may also provide closures to the `with()` method. These closures expect the mapper as their first parameter, and the source data as the second. By using closures you get access to the `$from` parameter as well, allowing you to do more advanced mapping via the `with()` method: + +```php +$result = map($rawBooksAsJson) + ->with(fn (ArrayToBooksMapper $mapper, array $books) => $mapper->map($books, Book::class)) + ->do(); +``` + +Of course, `with()` can also be combined with `collection()` and `to()`. + +```php +use function Tempest\map; + +$books = map($rawBooksAsJson) + ->collection() + ->with(ArrayToBooksMapper::class) + ->to(Book::class); +``` + +### Serializing to arrays or JSON + +You may call `toArray()` or `toJson()` on the mapper instance to serialize the mapped data to an array or JSON string, respectively. + +```php +$array = map($book)->toArray(); +$json = map($book)->toJson(); +``` + +### Overriding field names + +When mapping from an array to an object, Tempest will use the property names of the target class to map the data. If a property name doesn't match a key in the source array, you can use the {b`#[Tempest\Mapper\MapFrom]`} attribute to specify the key to map to the property. + +```php +use Tempest\Mapper\MapFrom; + +final class Book +{ + #[MapFrom('book_title')] + public string $title; +} +``` + +In the following example, the `book_title` key from the source array will be mapped to the `title` property of the `Book` class. + +```php +$book = map(['book_title' => 'Timeline Taxi'])->to(Book::class); +``` + +Similarly, you can use the {b`#[Tempest\Mapper\MapTo]`} attribute to specify the key that will be used when serializing the object to an array or a JSON string. + +```php +use Tempest\Mapper\MapFrom; + +final class Book +{ + #[MapTo('book_title')] + public string $title; +} +``` + +### Strict mapping + +By default, the mapper allows building objects with missing data. For instance, if you have a class with two properties, and you only provide data for one of them, the mapper will still create an instance of the class. + +This is useful for cases where you want to build objects incrementally. Similarly, protected and private properties are ignored and will not be populated. + +```php +final class Book +{ + public string $title; + public string $contents; +} + +$book = map(['title' => 'Timeline Taxi'])->to(Book::class); // This is allowed +``` + +Of course, accessing missing properties after the object has been constructed will result in an uninitialized property error. If you prefer to have the mapper throw an exception when properties are missing, you may mark the class or a specific property with the {`#[Tempest\Mapper\Strict]`} attribute. + +```php +use Tempest\Mapper\Strict; + +#[Strict] +final class Book +{ + public string $title; + public string $contents; +} + +// Not allowed anymore, MissingValuesException will be thrown +$book = map(['title' => 'Timeline Taxi'])->to(Book::class); +``` + +## Custom mappers + +You may create your own mappers by implementing the {`\Tempest\Mapper\Mapper`} interface. This interface expects a `canMap()` and a `map()` method. + +```php +final readonly class PsrRequestToRequestMapper implements Mapper +{ + public function canMap(mixed $from, mixed $to): bool + { + if (! $from instanceof PsrRequest) { + return false; + } + + return is_a($to, Request::class, allow_string: true); + } + + public function map(mixed $from, mixed $to): object + { /* … */ } +} +``` + +### Mapper discovery + +Tempest will try its best to find the right mapper for you. All classes that implement the {b`\Tempest\Mapper\Mapper`} interface will be automatically discovered and registered. + +Mapper discovery relies on the result of the `canMap()` method. When a mapper is dedicated to mapping a source to a specific class, the `$to` parameter may not necessarily be used. + +## Casters and serializers + +Casters are responsible for mapping serialized data to a complex type. Similarly, serializers convert complex types to a serialized representation. + +You may create your own casters and serializers by implementing the {`\Tempest\Mapper\Caster`} and {`\Tempest\Mapper\Serializer`} interfaces, respectively. + +```php app/AddressCaster.php +use Tempest\Mapper\Caster; + +final readonly class AddressCaster implements Caster +{ + public function cast(mixed $input): Address + { + return new Address( + street: $input['street'], + city: $input['city'], + postalCode: $input['postal_code'], + ); + } +} +``` + +```php app/AddressSerializer.php +use Tempest\Mapper\Serializer; + +final readonly class AddressSerializer implements Serializer +{ + public function serialize(mixed $input): array|string + { + if (! $input instanceof Address) { + throw new CannotSerializeValue(Address::class); + } + + return $input->toArray(); + } +} +``` + +Of course, Tempest provides casters and serializers for the most common data types, including arrays, booleans, dates, enumerations, integers and value objects. + +### Specifying casters or serializers for properties + +You may use a specific caster or serializer for a property by using the {b`#[Tempest\Mapper\CastWith]`} or {b`#[Tempest\Mapper\SerializeWith]`} attribute, respectively. + +```php +use Tempest\Mapper\CastWith; + +final class User +{ + #[CastWith(AddressCaster::class)] + public Address $address; +} +``` + +You may of course use {b`#[Tempest\Mapper\CastWith]`} and {b`#[Tempest\Mapper\SerializeWith]`} together. + +### Registering casters and serializers globally + +You may register casters and serializers globally, so you don't have to specify them for every property. This is useful for value objects that are used frequently. + +```php +use Tempest\Mapper\Casters\CasterFactory; +use Tempest\Mapper\Serializers\SerializerFactory; + +// Register a caster globally for a specific type +$container->get(CasterFactory::class) + ->addCaster(Address::class, AddressCaster::class); + +// Register a serializer globally for a specific type +$container->get(SerializerFactory::class) + ->addSerializer(Address::class, AddressSerializer::class); +``` + +If you're looking for the right place where to put this logic, [provider classes](/docs/extra-topics/package-development#provider-classes) is our recommendation. diff --git a/docs/2-features/02-asset-bundling.md b/docs/2-features/02-asset-bundling.md new file mode 100644 index 000000000..697292013 --- /dev/null +++ b/docs/2-features/02-asset-bundling.md @@ -0,0 +1,203 @@ +--- +title: Asset bundling +description: "Web applications usually need to serve assets to users. Tempest provide a seamless integration with Vite, the most popular front-end development server and build tool" +keywords: ["vite", "frontend", "js", "css", "ts", "typescript", "javascript", "sri", "manifest", "assets"] +--- + +## Overview + +[Vite](https://vite.dev) is the de-facto standard build tool for front-end development. It provides a very fast development server and bundles your assets for production without barely any configuration needed. + +Tempest provides an integration with Vite that consists of a [Vite plugin](https://github.com/tempestphp/tempest-framework/tree/main/packages/vite-plugin-tempest) and a [server-side package](https://github.com/tempestphp/tempest-framework/tree/main/src/Tempest/Vite). + +## Quick start + +To install Vite, you may run the corresponding installer command. The wizard will guide you through the installation, including adding the Vite plugin, the `vite.config.ts` configuration file, the TypeScript entrypoint, and, if you chose so, Tailwind CSS. + +```sh +php tempest install vite +``` + +The next step is to add the [`{html}`](../1-essentials/02-views.md#x-vite-tags) component to your base template. This is how the script and style tags including your entrypoints are provided to the browser. + +```html x-base.view.php + + + + + + + + + +``` + +## Running the development server + +During development, the purpose of Vite is to transpile asset files on-the-fly to a format that the browser understands. This is the concept that makes Vite really fast—it doesn't need to bundle the whole application everytime some code is updated. + +For Vite to be able to transpile assets, its server needs to be started. This is done by running its command-line interface, `vite`. + +```sh +npm run dev +``` + +The command above looks for the `dev` script in `package.json`, which in turns runs the `vite` CLI. This is the equivalent of running the `{sh}npx vite` command. + +## Entrypoints + +An entrypoint is a primary script or stylesheet that serves as the starting point in an application. Any asset file ending with `.entrypoint.{ts,css,js}` will automatically be discovered by Tempest, meaning you don't have to configure anything. + +```js app/main.entrypoint.ts +console.log('Hello, world! 🌊') +``` + +### Manually including an entrypoint + +It might happen that you only need a specific script or stylesheet in a particular view. In this situation, you may use the [`{html}`](./03-views#x-vite-tags) component with an `entrypoint` attribute that points to the file you want to include: + +```html app/Profile/show.view.php + + + + + + +``` + +:::warning +For Vite to bundle this file in production, it still needs to be [configured as an entrypoint](#manually-configuring-entrypoints). Otherwise, it will not be included in the production manifest, and Tempest won't be able to generate a link to it. +::: + +### Manually configuring entrypoints + +If you prefer, you may opt-out of the `*.entrypoint.{ts,cs,js}` naming convention and manually configure entrypoints in the Vite configuration. + +To do so, create a `vite.config.php` file that returns a {`Tempest\Vite\ViteConfig`} instance. You should configure the `entrypoints` parameter: + +```php app/vite.config.php +return new ViteConfig( + entrypoints: [ + 'app/main.css', + 'app/main.ts', + ], +); +``` + +Note that the paths to the entrypoint files must be relative to the root of the project. + +If you opted in for manual entrypoint configuration, your base template should also [specify which entrypoint to include by default](#manually-including-an-entrypoint). Otherwise, all configured entrypoints will be used. + +## Building for production + +Running the `build` script from the `package.json` will bundle your application's assets, versioning them and creating a `manifest.json` file. + +```sh +npm run build +``` + +By default, assets are compiled in the `public/build` directory. This directory should be added to `.gitignore`, to avoid adding compiled assets to version control. + +:::info +This directory is already in your `.gitignore` if you used the `{sh}php tempest install vite` command. +::: + +## Using a `nonce` attribute + +If your application uses a [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP), you may need to include `nonce` attributes to the tags generated by `{html}`. + +The value of a `nonce` attribute should be used only once per request, as it is mainly used to prevent [replay attacks](https://en.wikipedia.org/wiki/Replay_attack). To generate and configure that value for each request, you may use a [route middleware](../1-essentials/02-views.md#route-middleware): + +```php +use Tempest\Support\Random; +use Tempest\Vite\ViteConfig; + +final class ConfigureViteNonce implements HttpMiddleware +{ + public function __construct( + private readonly ViteConfig $viteConfig, + ) {} + + public function __invoke(Request $request, HttpMiddlewareCallable $next): Response + { + $this->viteConfig->nonce = Random\secure_string(length: 40); + + return $next($request); + } +} +``` + +Note that middleware are not automatically registered, as their order generally matters. You may manually include this middleware to routes that need it, or apply it automatically by registering it globally: + +```php +use Tempest\Core\KernelEvent; +use Tempest\EventBus\EventHandler; +use Tempest\Router\HttpMiddleware; +use Tempest\Router\HttpMiddlewareCallable; +use Tempest\Http\Request; +use Tempest\Http\Response; +use Tempest\Router\Router; +use Tempest\Support\Random; +use Tempest\Vite\ViteConfig; + +final class ConfigureViteNonce implements HttpMiddleware +{ + public function __construct( + private readonly Router $router, + private readonly ViteConfig $viteConfig, + ) {} + + public function __invoke(Request $request, HttpMiddlewareCallable $next): Response + { + $this->viteConfig->nonce = Random\secure_string(length: 40); + + return $next($request); + } + + #[EventHandler(KernelEvent::BOOTED)] + public function register(): void + { + $this->router->addMiddleware(self::class); + } +} +``` + +The `register` method above is an [event handler](../2-features/08-events.md) that is called when Tempest boots. It registers the middleware on the injected {`Tempest\Router\Router`} instance, effectively registering it for every route. + +Alternatively, you may also set the `nonce` directly in the event handler. However, keep in mind that this would be called every time the framework boots, even when only using console commands. + +## Subresource integrity + +Tempest will detect [subresource integrity](https://developer.mozilla.org/en-US/docs/Web/Security/Subresource_Integrity) hashes in your `manifest.json` file and will automatically add them to the generated script and style tags. + +Integrity hashes are not included in Vite manifests by default, but the `vite-plugin-manifest-sri` plugin provides this functionality. You may install it through `bun` or `npm` and register it like any other Vite plugin in your configuration file: + +```js vite.config.ts +import tailwindcss from '@tailwindcss/vite' +import { defineConfig } from 'vite' +import sri from 'vite-plugin-manifest-sri' +import tempest from 'vite-plugin-tempest' + +export default defineConfig({ + plugins: [ + tailwindcss(), + tempest(), + sri(), + ], +}) +``` + +## Testing + +By default, Tempest is intructed to not generate any tag during tests. This behavior is in place to prevent triggering `ManifestNotFoundException` exceptions in your test suite. + +If, for any reason, you wish to restore tag resolution in a test, you may call the `{php}allowTagResolution()` method on the `ViteTester` instance: + +```php tests/SomeTest.php +public function setUp(): void +{ + parent::setUp(); + + $this->vite->allowTagResolution(); +} +``` diff --git a/docs/2-features/03-validation.md b/docs/2-features/03-validation.md new file mode 100644 index 000000000..ccf70eab1 --- /dev/null +++ b/docs/2-features/03-validation.md @@ -0,0 +1,132 @@ +--- +title: Validation +description: "Tempest's validation is based on built-in PHP types, but provides many attribute-based rules to cover a wide variety of situations." +--- + +## Overview + +Tempest provides a {`\Tempest\Validation\Validator`} object capable of validating an array of values against the public properties of a class or an array of validation rules. + +While validation and [data mapping](./01-mapper) often work together, the two are separate components and can also be used separately. + +## Validating against objects + +When you have raw data and an associated model or data transfer object, you may use the `validateValuesForClass` method on the {b`\Tempest\Validation\Validator`}. + +```php +use Tempest\Validation\Validator; + +$validator = new Validator(); +$failingRules = $validator->validateValuesForClass(Book::class, [ + 'title' => 'Timeline Taxi', + 'description' => 'My sci-fi novel', + 'publishedAt' => '2024-10-01', +]); +``` + +This method accepts a fully-qualified class name as the first argument, and an array of data as the second. The values of the data array will be validated against the public properties of the class. + +In this case, validation works by inferring validation rules from the built-in PHP types. In the example above, the `Book` class has the following public properties: + +```php +use Tempest\DateTime\DateTime; + +final class Book +{ + public string $title; + public string $description; + public ?DateTime $publishedAt = null; +} +``` + +If validation fails, `validateValuesForClass()` returns a list of fields and their respective failed rules. + +### Adding more rules + +Most of the time, the built-in PHP types will not be enough to fully validate your data. You may then add validation attributes to the model or data transfer object. + +```php +use Tempest\Validation\Rules; + +final class Book +{ + #[Rules\Length(min: 5, max: 50)] + public string $title; + + #[Rules\NotEmpty] + public string $description; + + #[Rules\DateTimeFormat('Y-m-d')] + public ?DateTime $publishedAt = null; +} +``` + +A list of all available validation rules can be found on [GitHub](https://github.com/tempestphp/tempest-framework/tree/main/packages/validation/src/Rules). + +### Skipping validation + +You may have situations where you don't want specific properties on a model to be validated. In this case, you may use the {b`#[Tempest\Validation\SkipValidation]`} attribute to prevent them from being validated. + +```php +use Tempest\Validation\SkipValidation; + +final class Book +{ + #[SkipValidation] + public string $title; +} +``` + +## Validating against specific rules + +If you don't have a model or data transfer object to validate data against, you may alternatively use the `validateValues` and provide an array of rules. + +```php +$validator->validateValues([ + 'name' => 'Jon Doe', + 'email' => 'jon@doe.co', + 'age' => 25, +], [ + 'name' => [new IsString(), new NotNull()], + 'email' => [new Email()], + 'age' => [new IsInteger(), new NotNull()], +]); +``` + +If validation fails, `validateValues()` returns a list of fields and their respective failing rules. + +A list of all available validation rules can be found on [GitHub](https://github.com/tempestphp/tempest-framework/tree/main/packages/validation/src/Rules). + +## Validating a single value + +You may validate a single value against a set of rules using the `validateValue` method. + +```php +$validator->validateValue('jon@doe.co', [new Email()]); +``` + +Alternatively, you may provide a closure for validation. The closure should return `true` if validation passes, or `false` otherwise. You may also return a string to specify the validation failure message. + +```php +$validator->validateValue('jon@doe.co', function (mixed $value) { + return str_contains($value, '@'); +}); +``` + +## Accessing error messages + +When validation fails, a list of fields and their respective failing rules is returned. You may call the `message` method on any rule to get a validation message. + +```php +use Tempest\Support\Arr; + +// Validate some value +$failures = $validator->validateValue('jon@doe.co', new Email()); + +// Map failures to their message +$errors = Arr\map($failures, fn (Rule $failure) => $failure->message()); +``` + +:::info +Note that we expect to improve the way validation messages work in the future. See [this conversation](https://discord.com/channels/1236153076688359495/1294321824498323547/1294321824498323547) on our [Discord server](https://tempestphp.com/discord). +::: diff --git a/docs/2-features/04-authentication.md b/docs/2-features/04-authentication.md new file mode 100644 index 000000000..aac2083f2 --- /dev/null +++ b/docs/2-features/04-authentication.md @@ -0,0 +1,105 @@ +--- +title: Authentication and authorization +keywords: "Experimental" +--- + +:::warning +The authentication and authorization implementations of Tempest are currently experimental. Although you can use them, please note that they are not covered by our backwards compatibility promise. +::: + +## Overview + +Logging in (authentication) and verifying whether a user is allowed to perform a specific action (authorization) are two crucial parts of any web application. Tempest comes with a built-in authenticator and authorizer, as well as a base `User` and `Permission` model (if you want to). + +## Authentication + +Logging in a user can be done with the `Authenticator` class: + +```php +// app/AuthController.php + +use Tempest\Auth\Authenticator; +use Tempest\Http\Request; +use Tempest\Http\Response; +use Tempest\Http\Responses\Redirect; + +final readonly class AuthController +{ + public function __construct( + private Authenticator $authenticator + ) {} + + #[Post('/login')] + public function login(Request $request): Response + { + $user = // … + + $this->authenticator->login($user); + + return new Redirect('/'); + } +} +``` + +Note that Tempest currently doesn't provide user management support (resolving a user from a request, user registration, password reset flow, etc.). + +## Authorization + +You can protect controller routes using the `#[Allow]` attribute: + +```php +// app/AdminController.php + +use Tempest\Auth\Allow; +use Tempest\Http\Response; + +final readonly class AdminController +{ + #[Allow('permission')] + public function index(): Response + { + // … + } +} +``` + +Tempest uses a permission-based authorizer. That means that, in order for users to be allowed access to a route, they'll need to be granted the right permission. Permissions can be represented as strings or enums: + +```php +// app/AdminController.php + +use Tempest\Auth\Allow; +use Tempest\Http\Response; + +final readonly class AdminController +{ + #[Allow(UserPermission::ADMIN)] + public function index(): Response + { + // … + } +} +``` + +## Built-in user model + +Tempest's authenticator and authorizer are compatible with any class implementing the {`Tempest\Auth\CanAuthenticate`} and {`Tempest\Auth\CanAuthorize`} interfaces. However, Tempest comes with a pre-built `User` model that makes it easier to get started. In order to use Tempest's `User` implementation, you must install the auth files: + +``` +./tempest install auth +./tempest migrate:up +``` + +With this `User` model, you already have a lot of helper methods in place to build your own user management flow: + +```php +use App\Auth\User; + +$user = new User( + name: 'Brent', + email: 'brendt@stitcher.io', +) + ->setPassword('password') + ->save() + ->grantPermission('admin'); +``` diff --git a/docs/2-features/05-file-storage.md b/docs/2-features/05-file-storage.md new file mode 100644 index 000000000..e8a445d84 --- /dev/null +++ b/docs/2-features/05-file-storage.md @@ -0,0 +1,202 @@ +--- +title: File storage +description: "Tempest's storage provides a way to access many different types of filesystems, such as the local filesystem, Amazon S3, Cloudflare R2 or even an FTP server." +--- + +## Overview + +Tempest provides the ability to interact with the local filesystem and many cloud storage solutions, such as Cloudflare R2 or Amazon S3, using the same interface. + +This implementation is built on top of [Flysystem](https://github.com/thephpleague/flysystem)—a reliable, battle-tested abstraction layer for file systems. + +## Getting started + +To get started with file storage, you will first need to create a configuration file for your desired filesystem. + +Tempest provides a different configuration object for each provider. For instance, if you wish to interact with an Amazon S3 bucket, you may create a `s3.config.php` file returning an instance of {b`Tempest\Storage\Config\S3StorageConfig`}: + +```php app/s3.config.php +return new S3StorageConfig( + bucket: env('S3_BUCKET'), + region: env('S3_REGION'), + accessKeyId: env('S3_ACCESS_KEY_ID'), + secretAccessKey: env('S3_SECRET_ACCESS_KEY'), +); +``` + +In this example, the S3 credentials are specified in the `.env`, so a different bucket and credentials can be configured depending on the environment. + +Once your storage is configured, you may interact with it by using the {`Tempest\Storage\Storage`} interface. This is usually done through [dependency injection](../1-essentials/05-container.md#injecting-dependencies): + +```php app/UserService.php +final readonly class UserService +{ + public function __construct( + private Storage $storage, + ) {} + + public function getProfilePictureUrl(User $user): string + { + return $this->storage->publicUrl($user->profile_picture_path); + } + + // … +} +``` + +## The storage interface + +Once you have access to the the {b`Tempest\Storage\Storage`} interface, you gain access to a few useful methods for working with files, directory and streams. All methods are documented, so you are free to explore the source to get an understanding of what you can do with it. + +Below are a few useful methods that you may need more often than the others: + +```php +/** + * Gets a public URL to the file at the specified `$location`. + */ +$storage->publicUrl($location); + +/** + * Writes the given `$contents` to the specified `$location`. + */ +$storage->write($location, $contents); + +/** + * Reads the contents of the file at the specified `$location`. + */ +$storage->read($location); + +/** + * Deletes the contents of the file at the specified `$location`. + */ +$storage->delete($location); + +/** + * Determines whether a file exists at the specified `$location`. + */ +$storage->fileOrDirectoryExists($location); +``` + +## Configuration + +Tempest provides a different configuration object for each storage provider. Below are the ones that are currently supported: + +- {`Tempest\Storage\Config\LocalStorageConfig`} +- {`Tempest\Storage\Config\R2StorageConfig`} +- {`Tempest\Storage\Config\S3StorageConfig`} +- {`Tempest\Storage\Config\AzureStorageConfig`} +- {`Tempest\Storage\Config\FTPStorageConfig`} +- {`Tempest\Storage\Config\GoogleCloudStorageConfig`} +- {`Tempest\Storage\Config\InMemoryStorageConfig`} +- {`Tempest\Storage\Config\SFTPStorageConfig`} +- {`Tempest\Storage\Config\StorageConfig`} +- {`Tempest\Storage\Config\ZipArchiveStorageConfig`} +- {`Tempest\Storage\Config\CustomStorageConfig`} + +### Multiple storages + +If you need to work with multiple storage locations, you may create multiple storage configurations using tags. These tags may then be used to resolve the {b`Tempest\Storage\Storage`} interface, which will use the corresponding configuration. + +It's a good practice to use an enum for the tag: + +```php app/userdata.storage.config.php +return new S3StorageConfig( + tag: StorageLocation::USER_DATA, + bucket: env('USERDATA_S3_BUCKET'), + region: env('USERDATA_S3_REGION'), + accessKeyId: env('USERDATA_S3_ACCESS_KEY_ID'), + secretAccessKey: env('USERDATA_S3_SECRET_ACCESS_KEY'), +); +``` + +```php app/backup.storage.config.php +return new R2StorageConfig( + tag: StorageLocation::BACKUPS, + bucket: env('BACKUPS_R2_BUCKET'), + endpoint: env('BACKUPS_R2_ENDPOINT'), + accessKeyId: env('BACKUPS_R2_ACCESS_KEY_ID'), + secretAccessKey: env('BACKUPS_R2_SECRET_ACCESS_KEY'), +); +``` + +Once you have configured your storages and your tags, you may inject the {b`Tempest\Storage\Storage`} interface using the corresponding tag: + +```php app/BackupService.php +final readonly class BackupService +{ + public function __construct( + #[Tag(StorageLocation::BACKUPS)] + private Storage $storage, + ) {} + + // … +} +``` + +### Read-only storage + +A storage may be restricted to only allow read operations. Attempting to write to such a storage will result in a `League\Flysystem\UnableToWriteFile` exception being thrown. + +First, the `league/flysystem-read-only` adapter needs to be installed: + +```sh +composer require league/flysystem-read-only +``` + +Once this is done, you may pass the `readonly` parameter to the adapter configuration and set it to `true`. + +```php app/data-snapshots.storage.config.php +return new S3StorageConfig( + tag: StorageLocation::DATA_SNAPSHOTS, + readonly: true, + bucket: env('DATA_SNAPSHOTS_S3_BUCKET'), + region: env('DATA_SNAPSHOTS_S3_REGION'), + accessKeyId: env('DATA_SNAPSHOTS_S3_ACCESS_KEY_ID'), + secretAccessKey: env('DATA_SNAPSHOTS_S3_SECRET_ACCESS_KEY'), +); +``` + +### Custom storage + +If you need to implement your own adapter for an unsupported provider, you may do so by implementing the `League\Flysystem\FilesystemAdapter` interface. + +Tempest provides a {b`Tempest\Storage\Config\CustomStorageConfig`} configuration object which accepts any `FilesystemAdapter`, which will be resolved through the container. + +```php app/custom-storage.config.php +return new CustomStorageConfig( + adapter: App\MyCustomFilesystemAdapter::class, +); +``` + +## Testing + +By extending {`Tempest\Framework\Testing\IntegrationTest`} from your test case, you gain access to the storage testing utilities through the `storage` property. + +These utilities include a way to replace the storage with a testing implementation, as well as a few assertion methods related to files and directories. + +### Faking a storage + +You may generate a fake, testing-only storage by calling the `fake()` method on the `storage` property. This will replace the storage implementation in the container, and provide useful assertion methods. + +```php +// Replace the storage with a fake implementation +$storage = $this->storage->fake(); + +// Replace the specified storage with a fake implementation +$storage = $this->storage->fake(StorageLocation::DATA_SNAPSHOTS); + +// Asserts that the specified file exists +$storage->assertFileExists('file.txt'); +``` + +These fake storages are located in `.tempest/tests/storage`. They get erased every time the `fake()` method is called. To prevent this, you may set the `persist` argument to `true`. + +### Preventing storage access during tests + +It may be useful to prevent code from using any of the registered storages during tests. This could happen when forgetting to fake a storage for a specific test, for instance, and could result in unexpected costs when relying on a cloud storage provider. + +This may be achieved by calling the `preventUsageWithoutFake()` method on the `storage` property. + +```php tests/MyServiceTest.php +$this->storage->preventUsageWithoutFake(); +``` diff --git a/docs/2-features/06-cache.md b/docs/2-features/06-cache.md new file mode 100644 index 000000000..31a7c5b9e --- /dev/null +++ b/docs/2-features/06-cache.md @@ -0,0 +1,199 @@ +--- +title: Cache +description: "The cache component is based on Symfony's Cache, providing access to many different adapters through a convenient, simple interface." +--- + +## Getting started + +By default, Tempest uses a filesystem-based caching strategy. You may use a different cache back-end by creating a configuration file for the desired cache adapter. + + + +Once your cache is configured, you may interact with it by using the {`Tempest\Cache\Cache`} interface. This is usually done through [dependency injection](../1-essentials/05-container.md#injecting-dependencies): + +```php app/OrderService.php +use Tempest\Cache\Cache; +use Tempest\DateTime\Duration; + +final readonly class OrderService +{ + public function __construct( + private Cache $cache, + ) {} + + public function getOrdersCount(): int + { + return $this->cache->resolve( + key: 'orders_count', + resolve: fn () => $this->fetchOrdersCountFromDatabase(), + expiration: Duration::hours(12) + ); + } + + // … +} +``` + +## The cache interface + +Once you have access to the the {b`Tempest\Cache\Cache`} interface, you gain access to a few useful methods for working with cache items. All methods are documented, so you are free to explore the source to get an understanding of what you can do with it. + +Below are a few useful methods that you may need more often than the others: + +```php +/** + * Gets a value from the cache by the given key. + */ +$cache->get($key); + +/** + * Sets a value in the cache for the given key. + */ +$cache->put($key, $value); + +/** + * Gets a value from the cache by the given key, or resolve it using the given callback. + */ +$cache->resolve($key, function () { + return $this->expensiveOperation(); +}); +``` + +## Clearing the cache + +The cache may programmatically by cleared by calling the `clear()` method on a cache instance. However, it is sometimes useful to manually clear it. To do so, you may call the `cache:clear` command: + +```sh +./tempest cache:clear +``` + +By default, this would clear the main cache. If there are multiple configured caches, you will be prompted to choose which one to clear. + +## Disabling caches + +During development, all internal caches except the icon one are disabled. This is to ensure that you always get the latest changes when working on your application. + +In production, all caches are automatically enabled without you needing to tweak any configuration. In all environments, you may forcefully enable or disable caches by adding a dedicated environment variable to your `.env`. + +### Disabling project caches + +You may set the `CACHE_ENABLED` environment variable to `false` to forcefully disable your project cache. When disabled, the cache will not save any value and will return default values for getter methods. + +```ini .env +# Force-disables user cache +CACHE_ENABLED=false + +# Force-disables a tagged cache named `custom` +CACHE_CUSTOM_ENABLED=false +``` + +### Disabling internal caches + +Tempest has a few internal caches for views, discovery, configuration and icons. You may forcefully disable these caches, individually or all at once, by setting the following environment variables in your `.env` file: + +```ini .env +# Force-disables all internal caches +INTERNAL_CACHES=false + +# Force-disables the view cache +VIEW_CACHE=false + +# Force-disables the icon cache +ICON_CACHE=false + +# Force-disables the discovery cache +DISCOVERY_CACHE=false + +# Force-disables the config cache +CONFIG_CACHE=false +``` + +## Locks + +You may create a lock by calling the `lock()` method on a cache instance. After being created, the lock needs to be acquired by calling the `acquire()`, and released by calling the `release()` method. + +Alternatively, the `execute()` method may be used to acquire a lock, execute a callback, and release the lock automatically when the callback is done. + +```php +// Create the lock +$lock = $cache->lock('processing', Duration::seconds(30)); + +// Acquire the lock, do something and release it. +if ($lock->acquire()) { + $this->process(); + + $lock->release(); +} + +// Or using a callback, with an optional wait +// time if the lock is not yet available. +$lock->execute($this->process(...), wait: Duration::seconds(30)); +``` + +### Lock ownership + +Normally, a lock cannot be acquired if it is already held by another process. However, if you know the owner token, you may still access a lock by specifying the `owner` parameter. + +This may be useful to release a lock in an async command, for instance. + +```php +$cache->lock("processing:{$processId}", owner: $processId) + ->release(); +``` + +## Configuration + +Tempest provides a different configuration object for each cache provider. Below are the ones that are currently supported: + +- {`Tempest\Cache\Config\FilesystemCacheConfig`} +- {`Tempest\Cache\Config\InMemoryCacheConfig`} +- {`Tempest\Cache\Config\PhpCacheConfig`} + + + +## Testing + +By extending {`Tempest\Framework\Testing\IntegrationTest`} from your test case, you gain access to the cache testing utilities through the `cache` property. + +These utilities include a way to replace the cache with a testing implementation, as well as a few assertion methods related to cache items and locks. + +### Faking the cache + +You may generate a fake, testing-only cache by calling the `fake()` method on the `cache` property. This will replace the cache implementation in the container, and provide useful assertion methods. + +```php +// Replace the cache with a fake implementation +$cache = $this->cache->fake(); + +// Asserts that the specified cache key exists +$cache->assertCached('users_count'); + +// Asserts that the cache is empty +$cache->assertEmpty(); +``` + +### Testing locks + +Calling the `lock()` method on the cache testing utility will return a testing lock, which provides a few more testing utilities. + +```php +$cache = $this->cache->fake(); + +// Call some application code +// … + +$this->cache->assertNotLocked('processing'); +``` diff --git a/docs/2-features/07-mail.md b/docs/2-features/07-mail.md new file mode 100644 index 000000000..4d09f3167 --- /dev/null +++ b/docs/2-features/07-mail.md @@ -0,0 +1,298 @@ +--- +title: Mail +description: "Tempest provides a convenient layer built on top of Symfony's excellent mailer component so that you can send emails with ease." +--- + +## Getting started + +Sending emails starts with picking an email transport. Tempest comes with built-in support for SMTP, Amazon SES, and Postmark; but it's trivial to add any other transport you'd like. We'll start with plain SMTP, and explain how to switch to other transports later. + +By default, Tempest is configured to use SMTP mailing. You'll need to add these environment variables and the mailer will be ready for use: + +```dotenv +MAIL_SMTP_HOST=mail.my_provider.com +MAIL_SMTP_PORT=587 +MAIL_SMTP_USERNAME=my_username@my_provider.com +MAIL_SMTP_PASSWORD=my_password_123 +MAIL_SENDER_NAME=Brent +MAIL_SENDER_EMAIL=brendt@stitcher.io +``` + +Sending an email is done via the {b`\Tempest\Mail\Mailer`}, you can inject it anywhere you'd like: + +```php +use Tempest\Mail\Mailer; +use Tempest\Mail\GenericEmail; + +final class UserEventHandlers +{ + public function __construct( + private readonly Mailer $mailer, + ) {} + + #[EventHandler] + public function onCreated(UserCreated $userCreated): void + { + $this->mailer->send(new GenericEmail( + subject: 'Welcome!', + to: $userCreated->email, + html: view( + __DIR__ . '/mails/welcome.view.php', + user: $userCreated->user, + ), + )); + + $this->success('Done'); + } +} +``` + +Note that {b`\Tempest\Mail\GenericEmail`} is a default email implementation that can be used on the fly, but a more scalable approach would be to make individual classes for every email: + +```php +use Tempest\Mail\Mailer; +use Tempest\Mail\GenericEmail; + +final class UserEventHandlers +{ + public function __construct( + private readonly Mailer $mailer, + ) {} + + #[EventHandler] + public function onCreated(UserCreated $userCreated): void + { + $this->mailer->send(new WelcomeEmail($userCreated->user)); + + $this->success('Done'); + } +} +``` + +Here's what that `WelcomeEmail` would look like: + +```php +use Tempest\Mail\Email; +use Tempest\Mail\Envelope; +use Tempest\View\View; +use function Tempest\view; + +final class WelcomeEmail implements Email +{ + public function __construct( + private readonly User $user, + ) {} + + public Envelope $envelope { + get => new Envelope( + subject: 'Welcome', + to: $this->user->email, + ); + } + + public string|View $html { + get => view('welcome.view.php', user: $this->user); + } +} +``` + +Note how {b`\Tempest\Mail\Envelope`} contains all meta information about an email. Here you can specify the subject and receiver, but also headers, bcc, cc, and more. + +## Email content + +In the previous examples, we assumed there to be a [view](/docs/essentials/views) attached to an email. Views are flexible since they can contain variable data like the user object, for example. In simple cases though, you might only want to send HTML without it being a view. In that case, you can pass in the HTML like so: + +```php +use Tempest\Mail\Email; + +final class WelcomeEmail implements Email +{ + // … + + public string|View $html { + get => <<Thanks for joining! + HTML; + } +} +``` + +Whenever an email is sent, Tempest will automatically provide a text-only version of that email as well, which will be used by text-only email clients. The text is generated based on your HTML template (by stripping all the HTML tags). However, you also have the option to manually specify the text-only contents of an email, by implementing {b`Tempest\Mail\HasTextContent`}: + +```php +use Tempest\Mail\Email; +use Tempest\View\View; +use Tempest\Mail\HasTextContent; + +final class WelcomeEmail implements Email, HasTextContent +{ + // … + + public string|View|null $text = <<user); +} +``` + +```html welcome-text.view.php +Hello {{ $user->name }} + +Please visit this link to activate your account: {{ $user->activationLink }}. + +See you soon! + +Tempest +``` + +## Attachments + +If you want your email to have attachments, you can implement the {b`\Tempest\Mail\HasAttachments`} interface: + +```php +use Tempest\Mail\Attachment; +use Tempest\Mail\Email; +use Tempest\Mail\HasAttachments; + +final class WelcomeEmail implements Email, HasAttachments +{ + // … + + public array $attachments { + get => [ + Attachment::fromFilesystem(__DIR__ . '/welcome.pdf') + ]; + } +} +``` + +Creating attachments can be done in multiple ways: + +- By referencing a file directly on the filesystem (as shown in the previous example); +- By using a [storage drive](/docs/features/file-storage): `Attachment::fromStorage($s3Storage, '/welcome.pdf')`; +- Or by manually passing a closure to a new attachment instance: + +```php +use Tempest\Mail\Attachment; + +$attachment = new Attachment(function () { + return Pdf::createFromTemplate('user-pdf.pdf', user: $this->user); +}); +``` + +## Other transports + +As mentioned, Tempest has built-in support for SMTP, Amazon SES, and Postmark. It is however trivial to use a range of other transports as well. First let's talk about switching to one of the built-in transports. + +The first step in using any transport is to install the transport-specific driver. You can find a list of all supported transports on [Symfony's documentation](https://symfony.com/doc/current/mailer.html#using-a-3rd-party-transport). If we take Postmark as an example, you should install these two dependencies: + +``` +composer require symfony/postmark-mailer +composer require symfony/http-client +``` + +Next, create a new mail config file and return an instance of {b`Tempest\Mail\Transports\Postmark\PostmarkConfig`}: + +```php app/mail.config.php +use Tempest\Mail\Transports\Postmark\PostmarkConfig; +use function Tempest\env; + +return new PostmarkConfig( + key: env('MAIL_POSTMARK_TOKEN'), +); +``` + +Note that the Postmark token is the token associated with your Postmark account. A good practice is to also provide a default sender: + +```php app/mail.config.php +use Tempest\Mail\EmailAddress; +use Tempest\Mail\Transports\Postmark\PostmarkConfig; +use function Tempest\env; + +$defaultSender = null; + +if (env('MAIL_SENDER_NAME') && env('MAIL_SENDER_EMAIL')) { + $defaultSender = new EmailAddress( + email: env('MAIL_SENDER_EMAIL'), + name: env('MAIL_SENDER_NAME'), + ); +} + +return new PostmarkConfig( + key: env('MAIL_POSTMARK_TOKEN'), + defaultSender: $defaultSender, +); +``` + +Finally, make sure that all environment variables are correctly set, and you're done! Tempest's mailer will now route your emails via Postmark. + +## Creating your own transports + +While SMTP, Amazon SES, and Postmark are built in, there are a lot of [other transports available](https://symfony.com/doc/current/mailer.html#using-a-3rd-party-transport) as well. In order to use one of those, you must create a new config class, specifically for that transport. Here's an example of using Mailgun. First you require the Symfony driver: + +``` +composer require symfony/mailgun-mailer +``` + +Then you create a new config class, specifically for that transport: + +```php +final class MailgunConfig implements MailerConfig, ProvidesDefaultSender +{ + public string $transport = MailgunApiTransport::class; + + public function __construct( + public readonly EmailAddress $defaultSender, + #[SensitiveParameter] + private readonly string $key, + #[SensitiveParameter] + private readonly string $domain, + ) {} + + public function createTransport(): TransportInterface + { + return new MailgunTransportFactory() + ->create(Dsn::fromString("mailgun+api://{$this->key}:{$this->domain}@default")); + } +} +``` + +And finally, use it like so: + +```php app/mail.config.php +return new MailgunConfig( + defaultSender: $defaultSender, + key: env('MAIL_MAILGUN_KEY'), + domain: env('MAIL_MAILGUN_DOMAIN'), +); +``` + +## Testing + +Any test class extending from {b`\Tempest\Framework\Testing\IntegrationTest`} will have the {b`\Tempest\Mail\Testing\MailTester`} available: + +```php +public function test_welcome_mail() +{ + $this->mailer + ->send(new WelcomeEmail($this->user)) + ->assertSentTo($this->user->email) + ->assertAttached('welcome.pdf'); +} +``` + +Note that mails sent within tests using the {b`\Tempest\Mail\Testing\MailTester`} will never be actually sent. Read more about testing [here](/docs/essentials/testing). \ No newline at end of file diff --git a/docs/2-features/08-events.md b/docs/2-features/08-events.md new file mode 100644 index 000000000..e259656d4 --- /dev/null +++ b/docs/2-features/08-events.md @@ -0,0 +1,242 @@ +--- +title: Event bus +description: "Learn how to use Tempest's built-in event bus to dispatch events and decouple different components in your application." +--- + +## Overview + +An event bus is a synchronous communication system that allows different parts of an application to interact while being decoupled from each other. + +In Tempest, events can be anything from a scalar value to a simple data class. An event handler can be a closure or a class method, the former needing manual registration and the latter being automatically discovered by the framework. + +## Defining events + +Most events are typically simple data classes that store information relevant to the event. As a best practice, they should not include any logic. + +```php app/AircraftRegistered.php +final readonly class AircraftRegistered +{ + public function __construct( + public string $registration, + ) {} +} +``` + +When event classes are too much, you may also use scalar values—such as strings or enumerations—to define events. The latter is highly recommended for a better experience. + +```php app/AircraftLifecycle.php +enum AircraftLifecycle +{ + case REGISTERED; + case RETIRED; +} +``` + +## Dispatching events + +The {`Tempest\EventBus\EventBus`} interface implements a `dispatch()` method, which you may use to dispatch any event. The event bus may be [injected as a dependency](../1-essentials/01-container) like any other service: + +```php app/AircraftService.php +use Tempest\EventBus\EventBus; + +final readonly class AircraftService +{ + public function __construct( + public EventBus $eventBus, + ) {} + + public function register(Aircraft $aircraft): void + { + // … + + $this->eventBus->dispatch(new AircraftRegistered( + registration: $aircraft->icao_code, + )); + } +} +``` + +Alternatively, Tempest also provides the `\Tempest\event()` function. It accepts the same arguments as the {`Tempest\EventBus\EventBus`}'s `dispatch()` method, but uses [service location](../1-essentials/01-container#injected-properties) under the hood to access the event bus. + +## Handling events + +Events are only useful if they are listened for. In Tempest, this is done by calling the `listen()` method on the {b`Tempest\EventBus\EventBus`} instance, or by using the {b`#[Tempest\EventBus\EventHandler]`} attribute. + +### Global handlers + +Attribute-based event handling is most useful when events should be listened to application-wide. In other words, this is the option you should adopt when the associated event must be acted on every time it is dispatched. + +```php app/AircraftObserver.php +final readonly class AircraftObserver +{ + #[EventHandler] + public function onAircraftRegistered(AircraftRegistered $event): void + { + // … + } +} +``` + +### Local handlers + +When an event is only meant to be listened for in a specific situation, it is better to register it only when relevant. Such a situation could be, for instance, a [console command](../3-console/01-introduction) that needs logging when an event is dispatched. + +```php app/SyncUsersCommand.php +final readonly class SyncUsersCommand +{ + public function __construct( + private readonly Console $console, + private readonly UserService $userService, + private readonly EventBus $eventBus, + ) {} + + #[ConsoleCommand('users:sync')] + public function __invoke(AircraftRegistered $event): void + { + $this->console->header('Synchronizing users'); + + // Listen for the UserSynced to write to the console when it happens + $this->eventBus->listen(UserSynced::class, function (UserSynced $event) { + $this->console->keyValue($event->fullName, 'SYNCED'); + }); + + // Call external code that dispatches the UserSynced event + $this->userService->synchronize(); + } +} +``` + +## Event middleware + +When an event is dispatched, it is sent to the event bus, which then forwards it to all registered handlers. Similar to web requests and console commands, the event bus supports middleware. + +Event bus middleware can be used for various purposes, such as logging specific events, adding metadata, or performing other pre—or post-processing tasks. These middleware are defined as classes that implement the {`Tempest\EventBus\EventBusMiddleware`} interface. + +```php app/EventLoggerMiddleware.php +use Tempest\EventBus\EventBusMiddleware; +use Tempest\EventBus\EventBusMiddlewareCallable; + +final readonly class EventLoggerMiddleware implements EventBusMiddleware +{ + public function __construct( + private Logger $logger, + ) {} + + public function __invoke(string|object $event, EventBusMiddlewareCallable $next): void + { + $next($event); + + if ($event instanceof ShouldBeLogged) { + $this->logger->info($event->getLogMessage()); + } + } +} +``` + +### Middleware priority + +All event bus middleware classes get sorted based on their priority. By default, each middleware gets the "normal" priority, but you can override it using the `#[Priority]` attribute: + +```php +use Tempest\Core\Priority; + +#[Priority(Priority::HIGH)] +final readonly class EventLoggerMiddleware implements EventBusMiddleware +{ /* … */ } +``` + +Note that priority is defined using an integer. You can however use one of the built-in `Priority` constants: `Priority::FRAMEWORK`, `Priority::HIGHEST`, `Priority::HIGH`, `Priority::NORMAL`, `Priority::LOW`, `Priority::LOWEST`. + +### Middleware discovery + +Global event bus middleware classes are discovered and sorted based on their priority. You can make a middleware class non-global by adding the `#[SkipDiscovery]` attribute: + +```php +use Tempest\Discovery\SkipDiscovery; + +#[SkipDiscovery] +final readonly class EventLoggerMiddleware implements EventBusMiddleware +{ /* … */ } +``` + +## Built-in framework events + +Tempest includes a few built-in events that are primarily used internally. While most applications won’t need them, you are free to listen to them if desired. + +Most notably, the {`\Tempest\Core\KernelEvent`} enumeration defines the `BOOTED` and `SHUTDOWN` events, which are dispatched when the framework has [finished bootstrapping](../4-internals/01-bootstrap) and right before the process is exited, respectively. + +Other events include migration-related ones, such as {b`Tempest\Database\Migrations\MigrationMigrated`}, {b`Tempest\Database\Migrations\MigrationRolledBack`}, {b`Tempest\Database\Migrations\MigrationFailed`} and {b`Tempest\Database\Migrations\MigrationValidationFailed`}. + +## Testing + +By extending {`Tempest\Framework\Testing\IntegrationTest`} from your test case, you may gain access to the event bus testing utilities using the `eventBus` property. + +These utilities include a way to replace the event bus with a testing implementation, as well as a few assertion methods to ensure that events have been dispatched or are being listened to. + +```php +// Prevents events from being handled +$this->eventBus->preventEventHandling(); + +// Assert that an event has been dispatched +$this->eventBus->assertDispatched(AircraftRegistered::class); + +// Assert that an event has been dispatched multiple times +$this->eventBus->assertDispatched(AircraftRegistered::class, count: 2); + +// Assert that an event has been dispatched, +// and make custom assertions on the event object +$this->eventBus->assertDispatched( + event: AircraftRegistered::class, + callback: fn (AircraftRegistered $event) => $event->registration === 'LX-JFA' +); + +// Assert that an event has not been dispatched +$this->eventBus->assertNotDispatched(AircraftRegistered::class); + +// Assert that an event has an attached handler +$this->eventBus->assertListeningTo(AircraftRegistered::class); +``` + +### Preventing event handling + +When testing code that dispatches events, you may want to prevent Tempest from handling them. This can be useful when the event’s handlers are tested separately, or when the side-effects of these handlers are not desired for this test case. + +To disable event handling, the event bus instance must be replaced with a testing implementation in the container. This may be achieved by calling the `preventEventHandling()` method on the `eventBus` property. + +```php tests/MyServiceTest.php +$this->eventBus->preventEventHandling(); +``` + +### Testing a method-based handler + +When handlers are registered as methods, instead of dispatching the corresponding event to test the handler logic, you may simply call the method to test it in isolation. + +As an example, the following class contains an handler for the `AircraftRegistered` event: + +```php app/AircraftObserver.php +final readonly class AircraftObserver +{ + #[EventHandler] + public function onAircraftRegistered(AircraftRegistered $event): void + { + // … + } +} +``` + +This handler may be tested by resolving the service class from the container, and calling the method with an instance of the event created for this purpose. + +```php app/AircraftObserverTest.php +// Replace the event bus in the container +$this->eventBus->preventEventHandling(); + +// Resolve the service class +$observer = $this->container->get(AircraftObserver::class); + +// Call the event handler +$observer->onAircraftRegistered(new AircraftRegistered( + registration: 'LX-JFA', +)); + +// Assert that a mail has been sent, that the database contains something… +``` diff --git a/docs/2-features/09-logging.md b/docs/2-features/09-logging.md new file mode 100644 index 000000000..0b98496ca --- /dev/null +++ b/docs/2-features/09-logging.md @@ -0,0 +1,96 @@ +--- +title: Logging +--- + +Logging is an essential part of any developer's job. Whether it's for debugging or for production monitoring. Tempest has a powerful set of tools to help you access the relevant information you need. + +## Debug log + +First up are Tempest's debug functions: `ld()` (log, die), `lw()` (log, write), and `ll()` (log, log). These three functions are similar to Symfony's var dumper and Laravel's `dd()`, although there's an important difference. + +You can think of `ld()` or `lw()` as Laravel's `dd()` and `dump()` variants. In fact, Tempest uses Symfony's var-dumper under the hood, just like Laravel. Furthermore, if you haven't installed Tempest in a project that already includes Laravel, Tempest will also provide `dd()` and `dump()` as aliases to `ld()` and `lw()`. + +The main difference is that Tempest's debug functions will **also write to the debug log**, which can be tailed with tempest's built-in `tail` command. This is its default output: + +```console +./tempest tail + +

    Project

    Listening at /Users/brent/Dev/tempest-docs/log/tempest.log +

    Server

    No server log configured in LogConfig +

    Debug

    Listening at /Users/brent/Dev/tempest-docs/log/debug.log +``` + +Wherever you call `ld()` or `lw()` from, the output will also be written to the debug log, and tailed automatically with the `./tempest tail` command. On top of that, `tail` also monitors two other logs: + +- The **project log**, which contains everything the default logger writes to +- The **server log**, which should be manually configured in `LogConfig`: + +```php +// app/Config/log.config.php + +use Tempest\Log\LogConfig; + +return new LogConfig( + serverLogPath: '/path/to/nginx.log' + + // … +); +``` + +If you're only interested in tailing one or more specific logs, you can filter the `tail` output like so: + +```console +./tempest tail --debug + +

    Debug

    Listening at /Users/brent/Dev/tempest-docs/log/debug.log +``` + +Finally, the `ll()` function will do exactly the same as `lw()`, but **only write to the debug log, and not output anything in the browser or terminal**. + +## Logging channels + +On top of debug logging, Tempest includes a monolog implementation which allows you to log to one or more channels. Writing to the logger is as simple as injecting `\Tempest\Log\Logger` wherever you'd like: + +```php +// app/Rss.php + +use Tempest\Console\Console; +use Tempest\Console\ConsoleCommand; +use Tempest\Log\Logger; + +final readonly class Rss +{ + public function __construct( + private Console $console, + private Logger $logger, + ) {} + + #[ConsoleCommand] + public function sync() + { + $this->logger->info('Starting RSS sync'); + + // … + } +} +``` + +If you're familiar with [monolog](https://seldaek.github.io/monolog/), you know how it supports multiple handlers to handle a log message. Tempest adds a small layer on top of these handlers called channels, they can be configured within `LogConfig`: + +```php +// app/Config/log.config.php + +use Tempest\Log\LogConfig; +use Tempest\Log\Channels\AppendLogChannel; + +return new LogConfig( + channels: [ + new AppendLogChannel(path: __DIR__ . '/../log/project.log'), + ] +); +``` + +**Please note:** + +- Currently, Tempest only supports the `AppendLogChannel` and `DailyLogChannel`, but we're adding more channels in the future. You can always add your own channels by implementing `\Tempest\Log\LogChannel`. +- Also, it's currently not possible to configure environment-specific logging channels, this we'll also support in the future. Again, you're free to make your own channels that take the current environment into account. diff --git a/docs/2-features/10-command-bus.md b/docs/2-features/10-command-bus.md new file mode 100644 index 000000000..8ee2b1b7e --- /dev/null +++ b/docs/2-features/10-command-bus.md @@ -0,0 +1,169 @@ +--- +title: Command bus +keywords: "Experimental" +--- + +Tempest comes with a built-in command bus, which can be used to dispatch a command to its handler (synchronous or asynchronous). A command bus offers multiple advantages over a more direct approach to modelling processes: commands and their handlers can easily be tested in isolation, they are simple to serialize, and similar to the eventbus, the command bus also supports middleware. + +## Commands and handlers + +Commands themselves are simple data classes. They don't have to implement anything: + +```php +// app/CreateUser.php + +final readonly class CreateUser +{ + public function __construct( + public string $name, + public string $email, + public string $passwordHash, + ) {} +} +``` + +Just like controller actions and console commands, command handlers are discovered automatically: every method tagged with `#[CommandHandler]` will be registered as one. Tempest knows which command a method handles by looking at the type of the first parameter: + +```php +// app/UserHandlers.php + +use Tempest\CommandBus\CommandHandler; + +final class UserHandlers +{ + #[CommandHandler] + public function handleCreateUser(CreateUser $createUser): void + { + User::create( + name: $createUser->name, + email: $createUser->email, + password: $createUser->passwordHash, + ); + + // Send mail… + } +} +``` + +Note that handler method names can be anything: invokable methods, `handleCreateUser()`, `handle()`, `whateverYouWant()`, … + +Dispatching a command can be done with the `command()` function: + +```php +use function Tempest\command; + +command(new CreateUser($name)); +``` + +Alternatively to using the `command()` function, you can inject the `CommandBus`, and dispatch commands like so: + +```php +// app/UserController.php + +use Tempest\CommandBus\CommandBus; + +final readonly class UserController() +{ + public function __construct( + private CommandBus $commandBus, + ) {} + + public function create(): Response + { + // … + + $this->commandBus->dispatch(new CreateUser($name)); + } +} +``` + +## Async commands + +:::warning +The asynchronous commands implementation of Tempest is currently experimental. Although you can use it, please note that it is not covered by our backwards compatibility promise. +::: + +A common use case for Tempest's command bus is to dispatch asynchronous commands: commands that are executed by their handler in the background, outside the main PHP process. Making a command asynchronous is done by adding the `#[AsyncCommand]` to your command object: + +```php +// app/SendMail.php + +use Tempest\CommandBus\AsyncCommand; + +#[AsyncCommand] +final readonly class SendMail +{ + public function __construct( + public string $to, + public string $body, + ) {} +} +``` + +Besides adding the `#[AsyncCommand]` attribute, the flow remains exactly the same as if you were dispatching synchronous commands: + +```php +use function Tempest\command; + +command(new SendMail( + to: 'brendt@stitcher.io', + body: 'Hello!' +)); +``` + +In order to _run_ an asynchronous command, you'll have to run the `tempest command:monitor` console command. This is a long-running process, and you will need to set it up as a daemon on your production server. As long as `command:monitor` is running, async commands will be handled in the background. + +Note that async command handling is still an early feature, and will receive many improvements over time. + +## Command bus middleware + +Whenever commands are dispatched, they are passed to the command bus, which will pass the command along to each of its handlers. Similar to web requests and console commands, this command bus supports middleware. Command bus middleware can be used to, for example, do logging for specific commands, add metadata to commands, or anything else. Command bus middleware are classes that implement the `CommandBusMiddleware` interface, and look like this: + +```php +// app/MyCommandBusMiddleware.php + +use Tempest\CommandBus\CommandBusMiddleware; +use Tempest\CommandBus\CommandBusMiddlewareCallable; + +class MyCommandBusMiddleware implements CommandBusMiddleware +{ + public function __construct( + private Logger $logger, + ) {} + + public function __invoke(object $command, CommandBusMiddlewareCallable $next): void + { + $next($command); + + if ($command instanceof ShouldBeLogged) { + $this->logger->info($command->getLogMessage()); + } + } +} +``` + +### Middleware priority + +All command bus middleware classes get sorted based on their priority. By default, each middleware gets the "normal" priority, but you can override it using the `#[Priority]` attribute: + +```php +use Tempest\Core\Priority; + +#[Priority(Priority::HIGH)] +final readonly class MyCommandBusMiddleware implements CommandBusMiddleware +{ /* … */ } +``` + +Note that priority is defined using an integer. You can however use one of the built-in `Priority` constants: `Priority::FRAMEWORK`, `Priority::HIGHEST`, `Priority::HIGH`, `Priority::NORMAL`, `Priority::LOW`, `Priority::LOWEST`. + +### Middleware discovery + +Global command bus middleware classes are discovered and sorted based on their priority. You can make a middleware class non-global by adding the `#[SkipDiscovery]` attribute: + +```php +use Tempest\Discovery\SkipDiscovery; + +#[SkipDiscovery] +final readonly class MyCommandBusMiddleware implements CommandBusMiddleware +{ /* … */ } +``` diff --git a/docs/2-features/11-localization.md b/docs/2-features/11-localization.md new file mode 100644 index 000000000..3ff24b2bc --- /dev/null +++ b/docs/2-features/11-localization.md @@ -0,0 +1,168 @@ +--- +title: Localization +description: "Tempest provides convenient utilities for localizing applications, including a translator built on the MessageFormat 2.0 specification." +--- + +## Overview + +Tempest provides a simple {b`Tempest\Intl\Translator`} interface for localizing applications. It allows you to translate messages into different languages and formats them according to the current or specified locale. + +The translator implements the [MessageFormat 2.0](https://messageformat.unicode.org/) specification, which provides a flexible syntax for defining translation messages. This specification is [maintained by the Unicode project](https://github.com/unicode-org/message-format-wg) and is widely used in internationalization libraries. + +## Translating messages + +To translate messages, you may [inject](../1-essentials/05-container.md) the {`Tempest\Intl\Translator`} interface and use its `translate()` method. If the translation message accepts variables, you may pass them as named parameters. + +```php +$translator->translate('cart.expire_at', expire_at: $expiration); +// Your cart is valid until 1:30 PM +``` + +To translate a message in a specific locale, you may use the `translateForLocale()` instead and provide the {b`Tempest\Intl\Locale`} as the first parameter. + +```php +$translator->translateForLocale(Locale::FRENCH, 'cart.expire_at', expire_at: $expiration); +// Votre panier expire à 12h30 +``` + +Alternatively, you may use the `translate` or the `translate_for_locale` function in the `Tempest\Intl` namespace. + +### Configuring the locale + +The current locale is stored in the `currentLocale` property of the {`Tempest\Intl\IntlConfig`} [configuration object](../1-essentials/06-configuration.md). You may configure another default locale by creating a dedicated configuration file: + +```php intl.config.php +return new IntlConfig( + currentLocale: Locale::FRENCH, + fallbackLocale: Locale::ENGLISH, +); +``` + +By default, Tempest uses the [`intl.default_locale`](https://www.php.net/manual/en/locale.getdefault.php) ini value for the current locale. + +### Changing the locale + +You may update the current locale at any time by mutating the {b`Tempest\Intl\IntlConfig`} configuration object. For instance, this could be done in a [middleware](../1-essentials/01-routing.md#route-middleware): + +```php +final readonly class SetLocaleMiddleware implements HttpMiddleware +{ + public function __construct( + private Authenticator $authenticator, + private IntlConfig $intlConfig, + ) {} + + public function __invoke(Request $request, HttpMiddlewareCallable $next): Response + { + $this->intlConfig->currentLocale = $this->authenticator + ->currentUser() + ->preferredLocale; + + return $next($request); + } +} +``` + +## Defining translation messages + +Translation messages are usually stored in translation files. Tempest automatically [discovers](../4-internals/02-discovery.md) YAML and JSON translation files that use the `..{yaml,json}` naming format, where `` may be any string, and `` must be an [ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes) language code. + +For instance, you may store translation files in a `lang` directory: + +``` +src/ +└── lang/ + ├── messages.fr.yaml + └── messages.en.yaml +``` + +Alternatively, you may call the `add()` method on a {`Tempest\Intl\Catalog\Catalog`} instance to add a translation message at runtime. + +```php +$catalog->add(Locale::FRENCH, 'order.continue_shopping', 'Continuer vos achats'); +``` + +### Message syntax + +Tempest implements the [MessageFormat 2.0](https://messageformat.unicode.org/) specification, which provides a flexible syntax for defining translation messages. The syntax allows for variables, [pluralization](#pluralization), and [custom formatting functions](#custom-formatting-functions). + +Since most translation messages are multiline, YAML is the recommended format for defining them. Here is an example of a translation message that uses a [variable](https://messageformat.unicode.org/docs/reference/variables/), a [function](https://messageformat.unicode.org/docs/reference/functions/) and a function [parameter](https://messageformat.unicode.org/docs/reference/functions/#options): + +```yaml messages.en.yaml +today: + Today is {$today :datetime pattern=|yyyy/MM/dd|} +``` + +:::info +You may learn more about this syntax in the [MessageFormat documentation](https://messageformat.unicode.org/docs/translators/). +::: + +### Pluralization + +Pluralizing messages may be done using [matchers](https://messageformat.unicode.org/docs/reference/matchers/) and the `number` function. This syntax supports languages that have more than two plural categories. For instance, you may translate this sentence in Polish: + +```php messages.pl.yaml +cart: + items_count: + .input {$count :number} + .match $count + one {{Masz {$count} przedmiot.}} + few {{Masz {$count} przedmioty.}} + many {{Masz {$count} przedmiotów.}} + other {{Masz {$count} przedmiotów.}} +``` + +For more complex translation messages, you may also use multiple variables in a matcher. In this example, we use a `type` and a `count` variable in the same matcher. + +```php messages.pl.yaml +cart: + items_by_type_count: + .input {$type :string} + .input {$count :number} + .match $type $count + product one {{Masz {$count} produkt w koszyku.}} + product few {{Masz {$count} produkty w koszyku.}} + product many {{Masz {$count} produktów w koszyku.}} + product * {{Masz {$count} produktów w koszyku.}} + service one {{Masz {$count} usługę w koszyku.}} + service few {{Masz {$count} usługi w koszyku.}} + service many {{Masz {$count} usług w koszyku.}} + service * {{Masz {$count} usług w koszyku.}} + * one {{Masz {$count} element w koszyku.}} + * few {{Masz {$count} elementy w koszyku.}} + * many {{Masz {$count} elementów w koszyku.}} + * * {{Masz {$count} elementów w koszyku.}} +``` + +### Using markup + +Markup may be added to translation messages using a [dedicated syntax](https://messageformat.unicode.org/docs/reference/markup/) defined in the MessageFormat specification. Tempest provides a markup implementation that renders HTML tags and Iconify icons. + +```yaml +bold_text: "This is {#strong}bold{/strong}." +ui: + open_menu: "{#icon-tabler-menu/} Open menu" +``` + +It is possible to implement your own markup by implementing the {b`Tempest\Intl\MessageFormat\MarkupFormatter`} or {b`Tempest\Intl\MessageFormat\StandaloneMarkupFormatter`} interfaces. Classes implementing these interfaces are automatically discovered by Tempest. + +### Custom formatting functions + +The [MessageFormat 2.0](https://messageformat.unicode.org/) specification allows for defining custom formatting functions that can be used in translation messages. By default, Tempest provides formatting functions for strings, numbers and dates. + +You may define a custom formatting function by implementing the {b`Tempest\Intl\MessageFormat\FormattingFunction`} interface. For instance, the function for formatting dates is implemented as follows: + +```php +final class DateTimeFunction implements FormattingFunction +{ + public string $name = 'datetime'; + + public function evaluate(mixed $value, array $parameters): FormattedValue + { + $datetime = DateTime::parse($value); + $formatted = $datetime->format(Arr\get_by_key($parameters, 'pattern')); + + return new FormattedValue($value, $formatted); + } +} +``` diff --git a/docs/2-features/11-scheduling.md b/docs/2-features/11-scheduling.md new file mode 100644 index 000000000..3fb63d0fc --- /dev/null +++ b/docs/2-features/11-scheduling.md @@ -0,0 +1,61 @@ +--- +title: Scheduling +description: 'Tempest provides a modern and convenient way of scheduling tasks, which can be any class method, even existing console commands.' +--- + +## Overview + +Dealing with repeating, scheduled tasks is as simple as adding the {`#[Tempest\Console\Schedule]`} attribute to any class method. As with console commands, [discovery](../4-internals/02-discovery.md) takes care of finding these methods and registering them. + +## Using the scheduler + +To run tasks on your server, a single cron task is required. This task should call the `schedule:run` command, which will evaluate which scheduled task should be run at the current time. + +``` +0 * * * * user /path/to/{*tempest schedule:run*} +``` + +## Defining schedules + +Any method using the `{php}#[Schedule]` attribute will be run by the scheduler. As with everything Tempest, these methods are discovered automatically. + +```php app/ScheduledTasks.php +use Tempest\Console\Schedule; +use Tempest\Console\Scheduler\Every; + +final readonly class ScheduledTasks +{ + #[Schedule(Every::HOUR)] + public function updateSlackChannels(): void + { + // … + } +} +``` + +For most common scheduling use-cases, the {b`Tempest\Console\Scheduler\Every`} enumeration can be used. In case you need more fine-grained control, you can pass in an {b`Tempest\Console\Scheduler\Interval`} object instead: + +```php +use Tempest\Console\Schedule; +use Tempest\Console\Scheduler\Interval; + +#[Schedule(new Interval(hours: 2, minutes: 30))] +public function updateSlackChannels(): void +{ + // … +} +``` + +Note that scheduled task don't have to be console commands, but they can be both. This is handy when you need a task to be run on a schedule, but also want to be able to run it manually. + +```php +use Tempest\Console\ConsoleCommand; +use Tempest\Console\Schedule; + +#[Schedule(Every::HOUR)] +#[ConsoleCommand('slack:update-channels')] +public function updateSlackChannels(): void +{ + // … +} +``` diff --git a/docs/2-features/12-http-client.md b/docs/2-features/12-http-client.md new file mode 100644 index 000000000..91a671835 --- /dev/null +++ b/docs/2-features/12-http-client.md @@ -0,0 +1,5 @@ +--- +title: HTTP client +description: "" +hidden: true +--- diff --git a/docs/2-features/13-static-pages.md b/docs/2-features/13-static-pages.md new file mode 100644 index 000000000..a8f340293 --- /dev/null +++ b/docs/2-features/13-static-pages.md @@ -0,0 +1,118 @@ +--- +title: Static pages +description: "When rendering pages with no dynamic component, booting the whole framework is not necessary. Tempest provides a way to generate static pages that can be rendered directly from your web server." +--- + +## Overview + +When a controller action is tagged with {b`#[Tempest\Router\StaticPage]`}, it can be compiled by Tempest as a static HTML page. These pages can then directly be served directly through your web server. + +```php app/Marketing/FrontPageController.php +use Tempest\Router\Get; +use Tempest\Router\StaticPage; +use Tempest\View\View; +use function Tempest\view; + +final readonly class FrontPageController +{ + #[StaticPage] + #[Get('/')] + public function frontpage(): View + { + return view('./front-page'); + } +} +``` + +Compiling and cleaning up static pages is done using the `{txt}static:generate` and `{txt}static:clean` commands, respectively. Note that the latter removes all HTML files and empty directories in your `/public` directory. + +```sh +{:hl-comment:./tempest:} static:generate +{:hl-comment:./tempest:} static:clean +``` + +## Data providers + +Since most pages require some form of dynamic data, static pages can be assigned a data provider, which will generate multiple pages for one controller action. + +Let's take a look at the controller action for this very website: + +```php app/Documentation/ChapterController.php +use Tempest\Router\Get; +use Tempest\Router\StaticPage; +use Tempest\View\View; + +final readonly class ChapterController +{ + #[StaticPage(ChapterDataProvider::class)] + #[Get('/{category}/{slug}')] + public function show(string $category, string $slug, ChapterRepository $chapters): View + { + return new ChapterView( + repository: $chapters, + current: $chapters->find($category, $slug), + ); + } +} +``` + +In this case, the {b`#[Tempest\Router\StaticPage]`} attribute gets a reference to the `ChapterDataProvider`, which implements the {`\Tempest\Router\DataProvider`} interface: + +```php app/Documentation/ChapterDataProvider.php +use Tempest\Router\DataProvider; + +final readonly class DocsDataProvider implements DataProvider +{ + public function provide(): Generator + { + // … + } +} +``` + +A data provider's goal is to generate multiple pages for one controller action. It does so by yielding an array of controller action parameters for every page that needs to be generated. In case of the documentation chapter controller, the action needs a `$category` and `$slug`, as well as the chapter repository. + +That repository is injected by the container, so we don't need to worry about it here. What we do need to provide is a category and slug for each page we want to generate. + +In other words: we want to generate a page for every documentation chapter. We can use the `ChapterRepository` to get a list of all available chapters. Eventually, our data provider looks like this: + +```php app/Documentation/ChapterDataProvider.php +use Tempest\Router\DataProvider; + +final readonly class DocsDataProvider implements DataProvider +{ + public function __construct( + private ChapterRepository $chapters + ) {} + + public function provide(): Generator + { + foreach ($this->chapters->all() as $chapter) { + // Yield an array of parameters that should be passed to the controller action, + yield [ + 'category' => $chapter->category, + 'slug' => $chapter->slug, + ]; + } + } +} +``` + +The only thing left to do is to generate the static pages: + +```console +./tempest static:generate + +/framework/01-getting-started ............. /public/framework/01-getting-started/index.html +/framework/02-the-container ................. /public/framework/02-the-container/index.html +/framework/03-controllers ..................... /public/framework/03-controllers/index.html +/framework/04-views ................................. /public/framework/04-views/index.html +/framework/05-models ............................... /public/framework/05-models/index.html + +``` + +## Production + +Static pages are generated in the `/public` directory, as `index.html` files. Most web servers will automatically serve these static pages for you without any additional setup. + +Note that static pages are meant to be generated as part of your deployment script. That means the `{txt}./tempest static:generate` command should be in your deployment pipeline. diff --git a/docs/2-features/14-exception-handling.md b/docs/2-features/14-exception-handling.md new file mode 100644 index 000000000..e4bcd2ed9 --- /dev/null +++ b/docs/2-features/14-exception-handling.md @@ -0,0 +1,122 @@ +--- +title: Exception handling +description: "Learn how to gracefully handle exceptions in your application by writing exception processors." +--- + +## Overview + +Tempest comes with its own exception handler, which provides a simple way to catch and process exceptions. During local development, Tempest uses [Whoops](https://github.com/filp/whoops) to display detailed error pages. In production, it will show a generic error page. + +When an exception is thrown, it will be caught and piped through the registered exception processors. By default, the only registered exception processor, {b`Tempest\Core\LogExceptionProcessor`}, will simply log the exception. + +Of course, you may create your own exception processors. This is done by creating a class that implements the {`Tempest\Core\ExceptionProcessor`} interface. Classes implementing this interface are automatically [discovered](../4-internals/02-discovery.md), so you don't need to register them manually. + +## Reporting exceptions + +Sometimes, you may want to report an exception without necessarily throwing it. For example, you may want to log an exception, but not stop the execution of the application. To do this, you can use the `Tempest\report()` function. + +```php +use function Tempest\report; + +try { + // Some code that may throw an exception +} catch (SomethingFailed $e) { + report($e); +} +``` + +## Disabling default logging + +Exception processors are discovered when Tempest boots, then stored in the `exceptionProcessors` property of {`Tempest\Core\AppConfig`}. The default logging processor, {b`Tempest\Core\LogExceptionProcessor`}, is automatically added to the list of processors. + +To disable exception logging, you may remove it in a `KernelEvent::BOOTED` event handler: + +```php +use Tempest\Core\AppConfig; +use Tempest\Core\KernelEvent; +use Tempest\Core\LogExceptionProcessor; +use Tempest\EventBus\EventHandler; +use Tempest\Support\Arr; + +final readonly class DisableExceptionLogging +{ + public function __construct( + private AppConfig $appConfig, + ) { + } + + #[EventHandler(KernelEvent::BOOTED)] + public function __invoke(): void + { + Arr\forget_values($this->appConfig->exceptionProcessors, LogExceptionProcessor::class); + } +} +``` + +## Adding context to exceptions + +Sometimes, an exception may have information that you would like to be logged. By implementing the {`Tempest\Core\HasContext`} interface on an exception class, you can provide additional context that will be logged—and available to other processors. + +```php +use Tempest\Core\HasContext; + +final readonly class UserWasNotFound extends Exception implements HasContext +{ + public function __construct(private string $userId) + { + parent::__construct("User {$userId} not found."); + } + + public function context(): array + { + return [ + 'user_id' => $this->userId, + ]; + } +} +``` + +## Customizing the error page + +In production, when an uncaught exception occurs, Tempest displays a minimalistic, generic error page. You may customize this behavior by adding a middleware dedicated to catching {b`Tempest\Http\HttpRequestFailed`} exceptions. + +For instance, you may display a branded error page by providing a view: + +```php +use Tempest\Http\HttpRequestFailed; +use Tempest\Router\HttpMiddleware; +use function Tempest\view; + +final class CatchHttpRequestFailuresMiddleware implements HttpMiddleware +{ + public function __invoke(Request $request, HttpMiddlewareCallable $next): Response + { + try { + return $next($request); + } catch (HttpRequestFailed $failure) { + return new GenericResponse( + status: $failure->status, + body: view('./error.view.php', failure: $failure), + ); + } + } +} +``` + +## Testing + +By extending {`Tempest\Framework\Testing\IntegrationTest`} from your test case, you gain access to the exception testing utilities, which allow you to make assertions about reported exceptions. + +```php +// Prevents exceptions from being actually processed +$this->exceptions->preventReporting(); + +// Asserts that the exception was reported +$this->exceptions->assertReported(UserNotFound::class); + +// Asserts that the exception was not reported +$this->exceptions->assertNotReported(UserNotFound::class); + +// Asserts that no exceptions were reported +$this->exceptions->assertNothingReported(); +``` diff --git a/docs/2-features/15-datetime.md b/docs/2-features/15-datetime.md new file mode 100644 index 000000000..6b4d3df14 --- /dev/null +++ b/docs/2-features/15-datetime.md @@ -0,0 +1,156 @@ +--- +title: 'Date and time' +description: "Tempest provides a complete alternative to the DateTime implementation, with a higher-level API, deeply integrated into the framework." +keywords: ["timezone", "date", "time", "time zone", "carbon"] +--- + +## Overview + +PHP provides multiple date and time implementations. There is [`DateTime`](https://www.php.net/manual/en/class.datetime.php) and [`DateTimeImmutable`](https://www.php.net/manual/en/class.datetimeimmutable.php), based on [`DateTimeInterface`](https://www.php.net/manual/en/class.datetimeinterface.php), as well as [`IntlCalendar`](https://www.php.net/manual/en/class.intlcalendar.php). Unfortunately, those implementation have rough, low-level, awkward APIs, which are not pleasant to work with. + +Tempest provides an alternative to [`DateTimeInterface`](https://www.php.net/manual/en/class.datetimeinterface.php), partially based on [`IntlCalendar`](https://www.php.net/manual/en/class.intlcalendar.php). This implementation provides a better API with a more consistent interface. It was initially created by {x:azjezz} for the [PSL](https://github.com/azjezz/psl), and was ported to Tempest so it could be deeply integrated. + +## Creating date instances + +The {`Tempest\DateTime\DateTime`} class provides a `DateTime::parse()` method to create a date from a string, a timestamp, or another datetime instance. This is the most flexible way to create a date instance. + +```php +DateTime::parse('2025-09-19 02:00:00'); +``` + +Alternatively, if you know the format of the date string you are working with, you may use the `DateTime::fromPattern()`. It accepts a standard [ICU format](https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax). + +Finally, for more specific use cases, the `DateTime::fromString()` method may be used to create a date from a localized date and time string. + +### For the current date and time + +The recommended approach for getting the current time is by calling the `now()` method on the {`Tempest\Clock\Clock`} interface, [which may be injected as a dependency](#clock-interface) in any class. + +However, for convenience, you may also create a {b`Tempest\DateTime\DateTime`} instance for the current time using the `DateTime::now()` method or the `Tempest\now()` function. + +```php +$now = DateTime::now(); +``` + +### From a known format pattern + +If you know the format of the date string you are working with, you should prefer using the `DateTime::fromPattern()` method. It accepts a standard [ICU format](https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax). + +```php +DateTime::fromPattern('2025-09-19 02:00', pattern: 'yyyy-MM-dd HH:mm'); +``` + +## Manipulating dates + +The {b`Tempest\DateTime\DateTime`} class provides multiple methods to manipulate dates. You may add or subtract time from a date using the `plus()` and `minus()` methods, which accept a {b`Tempest\DateTime\Duration`} instance. + +For convenience, more specific manipulation methods are also provided. + +```php +// Adding a set duration +$date->plus(Duration::seconds(30)); + +// Using convenience methods +$date->plusHour(); +$date->plusMinutes(30); +$date->minusDay(); +$date->endOfDay(); +``` + +### Converting timezones + +All datetime creation methods accept a `timezone` parameter. This parameter accepts an instance of the {b`Tempest\DateTime\Timezone`} enumeration. When not provided, the default timezone, provided by [`date.timezone`](https://www.php.net/manual/en/datetime.configuration.php#ini.date.timezone), will be used. + +You may convert the timezone of an existing instance by calling the `convertToTimezone()` method: + +```php +use Tempest\DateTime\DateTime; +use Tempest\DateTime\Timezone; + +$date = DateTime::now(); +$date->convertToTimezone(Timezone::EUROPE_PARIS); +``` + +### Computing a duration + +By calling the `between()` method on a date instance, you may compute the duration between this date and a second one. This method returns a {b`Tempest\DateTime\Duration`} instance. + +```php +use Tempest\DateTime\DateTime; + +$date1 = DateTime::now(); +$date2 = DateTime::parse('2025-09-19 02:00:00'); +$duration = $date1->between($date2); +``` + +### Comparing dates + +The {b`Tempest\DateTime\DateTime`} instance provides multiple methods to compare dates against each other, or against the current time. For instance, you may check if a date is before or after another date using the `isBefore()` and `isAfter()` methods, respectively. + +```php +// Check if a date is before another date, inclusively +$date->isBefore($other); + +// Check if a date is before another date, exclusively +$date->isBeforeOrAtTheSameTime($other); + +// Check if a date between two other dates, inclusively +$date->betweenTimeInclusive($otherDate1, $otherDate2); + +// Check if a date is in the future +$date->isFuture(); +``` + +## Formatting dates + +You may format a {b`Tempest\DateTime\DateTime`} instance in a specific format using the `format()` method. This method accepts an optional format string, which is a standard [ICU format](https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax), and an optional locale. + +```php +use Tempest\DateTime\FormatPattern; +use Tempest\Intl\Locale; + +$date->format(); // 19 Sept 2025, 02:00:00 +$date->format(pattern: FormatPattern::COOKIE); // Monday, 19-Sept-2025 02:00:00 BST +$date->format(locale: Locale::FRENCH); // 19 sept. 2025, 02:00:00 +``` + +## Clock interface + +Tempest provides a {`Tempest\Clock\Clock`} interface which may be [injected as a dependency](../1-essentials/05-container.md#injecting-dependencies) in any class. This is the recommended way of working with time. + +```php +final readonly class HomeController +{ + public function __construct( + private readonly Clock $clock, + ) {} + + public function __invoke(): View + { + return view('./home.view.php', currentTime: $this->clock->now()); + } +} +``` + +Note that because Tempest has its own {b`Tempest\DateTime\DateTime`} implementation, the {b`Tempest\Clock\Clock`} interface is not compatible with PSR-20. However, you may get a PSR-20 implementation by calling the `toPsrClock()` method. + +```php +$psrClock = $clock->toPsrClock(); +``` + +## Testing time + +Tempest provides a time-related testing utilities accessible through the `clock` method of the [`IntegrationTest`](https://github.com/tempestphp/tempest-framework/blob/main/src/Tempest/Framework/Testing/IntegrationTest.php) test case. + +Calling this method replaces the {b`Tempest\Clock\Clock`} instance in the container with a testing one, on which a specific date and time can be defined. {b`Tempest\DateTime\DateTime`} instances created with the `DateTime::now()` method or `Tempest\now()` function will use the date and time specified by the testing clock. + +```php +// Create a testing clock +$clock = $this->clock(); + +// Set a specific date and time +$clock->setNow('2025-09-19 02:00:00'); + +// Advance time by the specified duration +$clock->sleep(milliseconds: 250); +``` diff --git a/docs/3-packages/01-highlight.md b/docs/3-packages/01-highlight.md new file mode 100644 index 000000000..647bc2334 --- /dev/null +++ b/docs/3-packages/01-highlight.md @@ -0,0 +1,757 @@ +--- +title: Highlight +description: "Tempest's highlighter is a package for server-side, high-performance, and flexible code highlighting." +--- + +## Quickstart + +Require `tempest/highlight` with composer: + +``` +composer require tempest/highlight +``` + +And highlight code like this: + +```php +$highlighter = new \Tempest\Highlight\Highlighter(); + +$code = $highlighter->parse($code, 'php'); +``` + +## Supported languages + +All supported languages can be found in the [GitHub repository](https://github.com/tempestphp/highlight/tree/main/src/Languages). + +## Themes + +There are a [bunch of themes](https://github.com/tempestphp/highlight/tree/main/src/Themes/Css) included in this package. You can load them either by importing the correct CSS file into your project's CSS file, or you can manually copy a stylesheet. + +```css +@import "../../../../../vendor/tempest/highlight/src/Themes/Css/highlight-light-lite.css"; +``` + +You can build your own CSS theme with just a couple of classes, copy over [the base stylesheet](https://github.com/tempestphp/highlight/tree/main/src/Themes/Css/highlight-light-lite.css), and make adjustments however you like. Note that `pre` tag styling isn't included in this package. + +### Inline themes + +If you don't want to or can't load a CSS file, you can opt to use the `InlineTheme` class. This theme takes the path to a CSS file, and will parse it into inline styles: + +```php +$highlighter = new Highlighter(new InlineTheme(__DIR__ . '/../src/Themes/Css/solarized-dark.css')); +``` + +### Terminal themes + +Terminal themes are simpler because of their limited styling options. Right now there's one terminal theme provided: `LightTerminalTheme`. More terminal themes are planned to be added in the future. + +```php +use Tempest\Highlight\Highlighter; +use Tempest\Highlight\Themes\LightTerminalTheme; + +$highlighter = new Highlighter(new LightTerminalTheme()); + +echo $highlighter->parse($code, 'php'); +``` + +![](/img/terminal.png) + +## Gutter + +This package can render an optional gutter if needed. + +```php +$highlighter = new Highlighter()->withGutter(startAt: 10); +``` + +The gutter will show additions and deletions, and can start at any given line number: + +```php{10} + public function before(TokenType $tokenType): string + { + $style = match ($tokenType) { +{- TokenType::KEYWORD => TerminalStyle::FG_DARK_BLUE, + TokenType::PROPERTY => TerminalStyle::FG_DARK_GREEN, + TokenType::TYPE => TerminalStyle::FG_DARK_RED,-} + TokenType::GENERIC => {+TerminalStyle::FG_DARK_CYAN+}, + TokenType::VALUE => TerminalStyle::FG_BLACK, + TokenType::COMMENT => TerminalStyle::FG_GRAY, + TokenType::ATTRIBUTE => TerminalStyle::RESET, + }; + + return TerminalStyle::ESC->value . $style->value; + } +``` + +Finally, you can enable gutter rendering on the fly if you're using [commonmark code blocks](#common-mark-integration) by appending {startAt} to the language definition: + +
    +```php{10}
    +echo 'hi'!
    +```
    +
    + +```php{10} +echo 'hi'! +``` + +## Special highlighting tags + +This package offers a collection of special tags that you can use within your code snippets. These tags won't be shown in the final output, but rather adjust the highlighter's default styling. All these tags work multi-line, and will still properly render its wrapped content. + +Note that highlight tags are not supported in terminal themes. + +### Emphasize, strong, and blur + +You can add these tags within your code to emphasize or blur parts: + +- {_ content _} adds the .hl-em class +- {* content *} adds the .hl-strong class +- {~ content ~} adds the .hl-blur class + +
    +{_Emphasized text_}
    +{*Strong text*}
    +{~Blurred text~}
    +
    + +This is the end result: + +```txt +{_Emphasized text_} +{*Strong text*} +{~Blurred text~} +``` + +### Additions and deletions + +You can use these two tags to mark lines as additions and deletions: + +- {+ content +} adds the `.hl-addition` class +- {- content -} adds the `.hl-deletion` class + +
    +{-public class Foo {}-}
    +{+public class Bar {}+}
    +
    + +```php +{-public class Foo {}-} +{+public class Bar {}+} +``` + +As a reminder: all these tags work multi-line as well: + +```php{1} + public function before(TokenType $tokenType): string + { + $style = match ($tokenType) { +{- TokenType::KEYWORD => TerminalStyle::FG_DARK_BLUE, + TokenType::PROPERTY => TerminalStyle::FG_DARK_GREEN, + TokenType::TYPE => TerminalStyle::FG_DARK_RED, + TokenType::GENERIC => TerminalStyle::FG_DARK_CYAN, + TokenType::VALUE => TerminalStyle::FG_BLACK, + TokenType::COMMENT => TerminalStyle::FG_GRAY, + TokenType::ATTRIBUTE => TerminalStyle::RESET,-} + }; + + return TerminalStyle::ESC->value . $style->value; + } +``` + +### Custom classes + +You can add any class you'd like by using the {:classname: content :} tag: + +
    +<style>
    +.hl-a {
    +    background-color: #FFFF0077;
    +}
    +
    +.hl-b {
    +    background-color: #FF00FF33;
    +}
    +</style>
    +
    +```php
    +{:hl-a:public class Foo {}:}
    +{:hl-b:public class Bar {}:}
    +```
    +
    + +```php +{:hl-a:public class Foo {}:} +{:hl-b:public class Bar {}:} +``` + +### Inline languages + +Within inline Markdown code tags, you can specify the language by prepending it between curly brackets: + +
    +`{php}public function before(TokenType $tokenType): string`
    +
    + +You'll need to set up [commonmark](#common-mark-integration) properly to get this to work. + +## CommonMark integration + +If you're using `league/commonmark`, you can highlight codeblocks and inline code like so: + +```php +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\MarkdownConverter; +use Tempest\Highlight\CommonMark\HighlightExtension; + +$environment = new Environment(); + +$environment + ->addExtension(new CommonMarkCoreExtension()) + ->addExtension(new HighlightExtension()); + +$markdown = new MarkdownConverter($environment); +``` + +Keep in mind that you need to manually install `league/commonmark`: + +``` +composer require league/commonmark; +``` + +## Implementing a custom language + +Let's explain how `tempest/highlight` works by implementing a new language — [Blade](https://laravel.com/docs/11.x/blade) is a good candidate. It looks something like this: + +```blade +@if(! empty($items)) +
    + Items: {{ count($items) }}. +
    +@endslot +``` + +In order to build such a new language, you need to understand _three_ concepts of how code is highlighted: _patterns_, _injections_, and _languages_. + +### Patterns + +A _pattern_ represents part of code that should be highlighted. A _pattern_ can target a single keyword like `return` or `class`, or it could be any part of code, like for example a comment: `/* this is a comment */` or an attribute: `#[Get(uri: '/')]`. + +Each _pattern_ is represented by a simple class that provides a regex pattern, and a `TokenType`. The regex pattern is used to match relevant content to this specific _pattern_, while the `TokenType` is an enum value that will determine how that specific _pattern_ is colored. + +Here's an example of a simple _pattern_ to match the namespace of a PHP file: + +```php +use Tempest\Highlight\IsPattern; +use Tempest\Highlight\Pattern; +use Tempest\Highlight\Tokens\TokenType; + +final readonly class NamespacePattern implements Pattern +{ + use IsPattern; + + public function getPattern(): string + { + return 'namespace (?[\w\\\\]+)'; + } + + public function getTokenType(): TokenType + { + return TokenType::TYPE; + } +} +``` + +Note that each pattern must include a regex capture group that's named `match`. The content that matched within this group will be highlighted. + +For example, this regex `namespace (?[\w\\\\]+)` says that every line starting with `namespace` should be taken into account, but only the part within the named group `(?…)` will actually be colored. In practice that means that the namespace name matching `[\w\\\\]+`, will be colored. + +Yes, you'll need some basic knowledge of regex. Head over to [https://regexr.com/](https://regexr.com/) if you need help, or take a look at the existing patterns in this repository. + +**In summary:** + +- Pattern classes provide a regex pattern that matches parts of code. +- Those regexes should contain a group named `match`, which is written like so `(?…)`, this group represents the code that will actually be highlighted. +- Finally, a pattern provides a `{php}TokenType`, which is used to determine the highlight style for the specific match. + +### Injections + +Once you've understood patterns, the next step is to understand _injections_. _Injections_ are used to highlight different languages within one code block. For example: HTML could contain CSS, which should be styled properly as well. + +An _injection_ will tell the highlighter that it should treat a block of code as a different language. For example: + +```html +
    + + + +
    +``` + +Everything within `{html}` tags should be treated as CSS. That's done by injection classes: + +```php +use Tempest\Highlight\Highlighter; +use Tempest\Highlight\Injection; +use Tempest\Highlight\IsInjection; +use Tempest\Highlight\ParsedInjection; + +final readonly class CssInjection implements Injection +{ + use IsInjection; + + public function getPattern(): string + { + return ' + +Some people or projects might want more fine-grained control over how specific words are coloured. A common example are `null`, `true`, and `false` in json files. By default, `tempest/highlight` will treat those value as normal text, and won't apply any special highlighting to them: + +```json +{ + "null-property": null, + "value-property": "value" +} +``` + +However, it's super trivial to add your own, extended styling on these kinds of tokens. Start by adding a custom language, let's call it `ExtendedJsonLanguage`: + +```php +use Tempest\Highlight\Languages\Json\JsonLanguage; + +class ExtendedJsonLanguage extends JsonLanguage +{ + public function getPatterns(): array + { + return [ + ...parent::getPatterns(), + ]; + } +} +``` + +Next, let's add a pattern that matches `null`: + +```php +use Tempest\Highlight\IsPattern; +use Tempest\Highlight\Pattern; +use Tempest\Highlight\Tokens\DynamicTokenType; +use Tempest\Highlight\Tokens\TokenType; + +final readonly class JsonNullPattern implements Pattern +{ + use IsPattern; + + public function getPattern(): string + { + return '\: (?null)'; + } + + public function getTokenType(): TokenType + { + return new DynamicTokenType('hl-null'); + } +} +``` + +Note how we return a `{php}DynamicTokenType` from the `{php}getTokenType()` method. The value passed into this object will be used as the classname for this token. + +Next, let's add this pattern in our newly created `{php}ExtendedJsonLanguage`: + +```php +class ExtendedJsonLanguage extends JsonLanguage +{ + public function getPatterns(): array + { + return [ + ...parent::getPatterns(), + {*new JsonNullPattern(),*} + ]; + } +} +``` + +Finally, register `{php}ExtendedJsonLanguage` into the highlighter: + +```php +$highlighter->addLanguage(new ExtendedJsonLanguage()); +``` + +Note that, because we extended `{php}JsonLanguage`, this language will target all code blocks tagged as `json`. You could provide a different name, if you want to make a distinction between the default implementation and yours (this is what's happening on this page): + +```php +class ExtendedJsonLanguage extends JsonLanguage +{ + public function getName(): string + { + return 'json_extended'; + } + + // … +} +``` + +There we have it! + +```json_extended +{ + "null-property": null, + "value-property": "value" +} +``` + +You can add as many patterns as you like, you can even make your own `{php}TokenType` implementation if you don't want to rely on `{php}DynamicTokenType`: + +```php +enum ExtendedTokenType: string implements TokenType +{ + case VALUE_NULL = 'null'; + case VALUE_TRUE = 'true'; + case VALUE_FALSE = 'false'; + + public function getValue(): string + { + return $this->value; + } + + public function canContain(TokenType $other): bool + { + return false; + } +} +``` + +## Opt-in features + +`tempest/highlight` has a couple of opt-in features, if you need them. + +### Markdown support + +``` +composer require league/commonmark; +``` + +```php +use League\CommonMark\Environment\Environment; +use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension; +use League\CommonMark\MarkdownConverter; +use Tempest\Highlight\CommonMark\HighlightExtension; + +$environment = new Environment(); + +$environment + ->addExtension(new CommonMarkCoreExtension()) + ->addExtension(new HighlightExtension(/* You can manually pass in configured highlighter as well */)); + +$markdown = new MarkdownConverter($environment); +``` + +### Word complexity + +Ellison is a simple library that helps identify complex sentences and poor word choices. It uses similar heuristics to Hemingway, but it doesn't include any calls to third-party APIs or LLMs. Just a bit of PHP: + +```ellison +The app highlights lengthy, complex sentences and common errors; if you see a yellow sentence, shorten or split it. If you see a red highlight, your sentence is so dense and complicated that your readers will get lost trying to follow its meandering, splitting logic — try editing this sentence to remove the red. + +You can utilize a shorter word in place of a purple one. Click on highlights to fix them. + +Adverbs and weakening phrases are helpfully shown in blue. Get rid of them and pick words with force, perhaps. + +Phrases in green have been marked to show passive voice. +``` + +You can enable Ellison support by installing [`assertchris/ellison`](https://github.com/assertchris/ellison-php): + +``` +composer require assertchris/ellison +``` + +You'll have to add some additional CSS classes to your stylesheet as well: + +```css +.hl-moderate-sentence { + background-color: #fef9c3; +} + +.hl-complex-sentence { + background-color: #fee2e2; +} + +.hl-adverb-phrase { + background-color: #e0f2fe; +} + +.hl-passive-phrase { + background-color: #dcfce7; +} + +.hl-complex-phrase { + background-color: #f3e8ff; +} + +.hl-qualified-phrase { + background-color: #f1f5f9; +} + +pre[data-lang="ellison"] { + text-wrap: wrap; +} +``` + +The `ellison` language is now available: + +
    +```ellison
    +Hello world!
    +```
    +
    + +You can play around with it [here](/ellison). diff --git a/docs/3-packages/02-console.md b/docs/3-packages/02-console.md new file mode 100644 index 000000000..d7d3a7a7a --- /dev/null +++ b/docs/3-packages/02-console.md @@ -0,0 +1,64 @@ +--- +title: Console +description: "The console component can be used as a standalone package to build console applications." +--- + +## Installation and usage + +Tempest's console component can be used standalone. You simply need to require the `tempest/console` package: + +```sh +composer require tempest/console +``` + +Once installed, you may boot a console application as follows. + +```php ./my-cli +{:hl-comment:#!/usr/bin/env php:} +run(); +``` + +## Registering commands + +`tempest/console` relies on [discovery](../4-internals/02-discovery.md) to find and register console commands. That means you don't have to register any commands manually, and any method within your codebase using the `{php}#[ConsoleCommand]` attribute will automatically be discovered by your console application. + +You may read more about building commands in the [dedicated documentation](../1-essentials/04-console-commands.md). + +## Configuring discovery + +Tempest will discover all console commands within namespaces configured as valid PSR-4 namespaces, as well as all third-party packages that require Tempest. + +```json +{ + "autoload": { + "psr-4": { + "App\\": "app/" + } + } +} +``` + +In case you need more fine-grained control over which directories to discover, you may provide a custom {`Tempest\Core\AppConfig`} instance to the `{php}ConsoleApplication::boot()` method: + +```php +use Tempest\AppConfig; +use Tempest\Core\DiscoveryLocation; +use Tempest\Console\ConsoleApplication; + +$appConfig = new AppConfig( + discoveryLocations: [ + new DiscoveryLocation( + namespace: 'App\\', + path: __DIR__ . '/app/', + ), + ], +); + +ConsoleApplication::boot(appConfig: $appConfig)->run(); +``` diff --git a/docs/4-internals/01-bootstrap.md b/docs/4-internals/01-bootstrap.md new file mode 100644 index 000000000..8327868c9 --- /dev/null +++ b/docs/4-internals/01-bootstrap.md @@ -0,0 +1,15 @@ +--- +title: Framework bootstrap +description: "Learn the steps involved in bootstrapping the framework." +--- + +## Overview + +Here's a short summary of what booting Tempest looks like. + +- The entry point is either `public/index.html` or `./tempest`. +- Tempest boots using the {b`\Tempest\Core\FrameworkKernel`}. +- Bootstrap classes are located in the [`Tempest\Core\Kernel`](https://github.com/tempestphp/tempest-framework/tree/main/packages/core/src/Kernel) namespace. +- First, discovery is started through the {b`\Tempest\Core\LoadDiscoveryLocations`} and {b`\Tempest\Core\LoadDiscoveryClasses`} classes. +- Then, configuration files are registered through the {b`\Tempest\Core\LoadConfig`} class. +- When bootstrapping is completed, the `Tempest\Core\KernelEvent::BOOTED` event is fired. diff --git a/docs/4-internals/02-discovery.md b/docs/4-internals/02-discovery.md new file mode 100644 index 000000000..16a4c09e5 --- /dev/null +++ b/docs/4-internals/02-discovery.md @@ -0,0 +1,217 @@ +--- +title: Discovery +description: "Learn how Tempest automatically locates controller actions, event handlers, console commands, and other components of your application." +--- + +## Overview + +Tempest introduces a unique approach to bootstrapping an application. Instead of requiring manual registration of project code and packages, Tempest automatically scans the codebase and detects the components that should be loaded. This process is called **discovery**. + +Discovery is powered by composer metadata. Every package that depends on Tempest, along with your application's own code, are included in the discovery process. Tempest applies various rules to determine the purpose of different pieces of code. It can analyze file names, attributes, interfaces, return types, and more. + +For instance, web routes are discovered based on route attributes: + +```php app/HomeController.php +final readonly class HomeController +{ + #[Get(uri: '/home')] + public function __invoke(): View + { + return view('home.view.php'); + } +} +``` + +Note that Tempest is able to cache discovery information to avoid any performance cost. Enabling this cache in production is highly recommended. + +## Built-in discovery classes + +Most of Tempest's features are built on top of discovery. The following describes which discovery class is associated to which feature. + +- {`\Tempest\Core\DiscoveryDiscovery`}
    + Discovers other discovery classes. This class is run manually by the framework when booted. +- {`\Tempest\CommandBus\CommandBusDiscovery`}
    + Discovers methods with the `#[CommandHandler]` attribute and registers them into the command bus. +- {`\Tempest\Console\Discovery\ConsoleCommandDiscovery`}
    + Discovers methods with the `#[ConsoleCommand]` attribute and registers them as console commands. +- {`\Tempest\Console\Discovery\ScheduleDiscovery`}
    + Discovers methods with the `#[Schedule]` attribute and registers them as scheduled tasks. +- {`\Tempest\Container\InitializerDiscovery`}
    + Discovers classes that implement {b`\Tempest\Container\Initializer`} or {b`\Tempest\Container\DynamicInitializer`} and registers them in the container. +- {`\Tempest\Database\MigrationDiscovery`}
    + Discovers classes that implement {`\Tempest\Database\Migration`} and registers them in the migration manager. +- {`\Tempest\EventBusDiscovery\EventBusDiscovery`}
    + Discovers methods with the `#[EventHandler]` attribute and registers them in the event bus. +- {`\Tempest\Router\RouteDiscovery`}
    + Discovers route attributes on methods and registers them as controller actions in the router. +- {`\Tempest\Mapper\MapperDiscovery`}
    + Discovers classes that implement {`\Tempest\Mapper\Mapper`}, which are registered in `\Tempest\Mapper\ObjectFactory` +- {`\Tempest\View\ViewComponentDiscovery`}
    + Discovers classes that implement {`\Tempest\View\ViewComponent`}, as well as view files that contain `{html}` or named `x-*.view.php` +- {`\Tempest\Vite\ViteDiscovery`}
    + Discovers `*.entrypoint.{ts,js,css}` files and register them as entrypoints. + +## Implementing your own discovery + +### Discovering code in classes + +Tempest will discover classes that implement {`\Tempest\Discovery\Discovery`}. You may create one, and implement the `discover()` and `apply` methods. + +The `discover()` method accepts a {b`Tempest\Core\DiscoveryLocation`} and a {b`Tempest\Reflection\ClassReflector`} parameter. You may use the latter to loop through a class' attributes, methods, parameters or anything else. + +If you find what you are interested in, you may register it using `$this->discoveryItems->add()`. As an example, the following is a simplified version of the event bus discovery: + +```php EventBusDiscovery.php +use Tempest\Discovery\Discovery; +use Tempest\Discovery\IsDiscovery; + +final readonly class EventBusDiscovery implements Discovery +{ + // This provides the default implementation for `Discovery`'s internals + use IsDiscovery; + + public function __construct( + // Discovery classes are autowired, + // so you can inject all dependencies you need + private EventBusConfig $eventBusConfig, + ) { + } + + public function discover(DiscoveryLocation $location, ClassReflector $class): void + { + foreach ($class->getPublicMethods() as $method) { + $eventHandler = $method->getAttribute(EventHandler::class); + + // Extra checks to determine whether + // we can actually use the current method as an event handler + + // … + + // Finally, we add all discovery-related data into `$this->discoveryItems`: + $this->discoveryItems->add($location, [$eventName, $eventHandler, $method]); + } + } + + // Next, the `apply` method is called whenever discovery is ready to be + // applied into the framework. In this case, we want to loop over all + // registered discovery items, and add them to the event bus config. + public function apply(): void + { + foreach ($this->discoveryItems as [$eventName, $eventHandler, $method]) { + $this->eventBusConfig->addClassMethodHandler( + event: $eventName, + handler: $eventHandler, + reflectionMethod: $method, + ); + } + } +} +``` + +### Discovering files + +In some situations, you may want to not just discover classes, but also files. For instance, view files, front-end entrypoints or SQL migrations are not PHP classes, but still need to be discovered. + +In this case, you may implement the additional {`\Tempest\Discovery\DiscoversPath`} interface. It will allow a discovery class to discover all paths that aren't classes as well. As an example, below is a simplified version of the Vite discovery: + +```php +use Tempest\Discovery\Discovery; +use Tempest\Discovery\DiscoversPath; +use Tempest\Discovery\IsDiscovery; + +final class ViteDiscovery implements Discovery, DiscoversPath +{ + use IsDiscovery; + + public function __construct( + private readonly ViteConfig $viteConfig, + ) {} + + // We are not discovering any class, so we return immediately. + public function discover(DiscoveryLocation $location, ClassReflector $class): void + { + return; + } + + // This method is called for every file in registered discovery locations. + // We can use the `$path` to determine whether we are interested in it. + public function discoverPath(DiscoveryLocation $location, string $path): void + { + // We are insterested in `.ts`, `.css` and `.js` files only. + if (! ends_with($path, ['.ts', '.css', '.js'])) { + return; + } + + // These files need to be specifically marked as `.entrypoint`. + if (! str($path)->beforeLast('.')->endsWith('.entrypoint')) { + return; + } + + $this->discoveryItems->add($location, [$path]); + } + + // When discovery is cached, `discover` and `discoverPath` are not called. + // Instead, `discoveryItems` is already fed with serialized data, which + // we can use. In this case, we add the paths to the Vite config. + public function apply(): void + { + foreach ($this->discoveryItems as [$path]) { + $this->viteConfig->addEntrypoint($path); + } + } +} +``` + +## Discovery in production + +While discovery is a really powerful feature, it also comes with some performance considerations. In production environments, you need to make sure that the discovery workflow is cached. This is done by using the `DISCOVERY_CACHE` environment variable: + +```env .env +{:hl-property:DISCOVERY_CACHE:}={:hl-keyword:true:} +``` + +The most important step is to generate that cache. This is done by running the `discovery:generate`, which should be part of your deployment pipeline. Make sure to run it before any other Tempest command. + +```console +./tempest discovery:generate + ℹ Clearing existing discovery cache… + ✓ Discovery cached has been cleared + ℹ Generating new discovery cache… (cache strategy used: all) + ✓ Cached 1119 items +``` + +## Discovery for local development + +By default, the discovery cache is disabled in a development environment. Depending on your local setup, it is likely that you will not run into noticeable slowdowns. However, for larger projects, you might benefit from enabling a partial discovery cache: + +```env .env +{:hl-property:DISCOVERY_CACHE:}={:hl-keyword:partial:} +``` + +This caching strategy will only cache discovery for vendor files. For this reason, it is recommended to run `discovery:generate` after every composer update: + +```json +{ + "scripts": { + "post-package-update": [ + "php tempest discovery:generate" + ] + } +} +``` + +:::info +Note that, if you've created your project using {`tempest/app`}, you'll have the `post-package-update` script already included. You may read the [internal documentation about discovery](../3-internals/02-discovery) to learn more. +::: + +## Excluding files and classes from discovery + +If needed, you can always exclude discovered files and classes by providing a discovery config file: + +```php app/discovery.config.php +use Tempest\Core\DiscoveryConfig; + +return new DiscoveryConfig() + ->skipClasses(GlobalHiddenDiscovery::class) + ->skipPaths(__DIR__ . '/../../Fixtures/GlobalHiddenPathDiscovery.php'); +``` diff --git a/docs/4-internals/03-view-spec.md b/docs/4-internals/03-view-spec.md new file mode 100644 index 000000000..b48d7af16 --- /dev/null +++ b/docs/4-internals/03-view-spec.md @@ -0,0 +1,651 @@ +--- +title: View specifications +description: Read the technical specifications for Tempest View, our templating language. +--- + +Tempest View is a server-side templating engine powered by PHP. Most of its syntax is inspired by [Vue.js](https://vuejs.org/). Tempest View aims to stay as close as possible to HTML, using PHP where needed. All syntax builds on top of HTML and PHP so that developers don't need to learn any new syntax. + +## Basic Syntax + +### Expression attributes + +Whenever an attribute starts with `:`, it's considered to be an expression attribute and its contents will be interpreted as PHP code. Common examples are control structures or data-passing. + +```html +
    + + +``` + +### Escaped expression attributes + +Some frontend frameworks also provide a `{html}:{:hl-property:attribute:}` syntax, these attributes can be escaped by using a double `::`: + +```html +
    +``` + +### Control structures + +Control structures like conditionals and loops are modelled with expression attributes. These control structure attributes are available: `{html}:{:hl-property:if:}`, `{html}:{:hl-property:elseif:}`, `{html}:{:hl-property:else:}`, `{html}:{:hl-property:foreach:}`, `{html}:{:hl-property:forelse:}`. Code within these control structures is compiled to valid PHP expressions. + +The following conditional: + +```html +
    A
    +
    B
    +
    C
    +``` + +Will compile to: + +```html + +
    A
    + +
    B
    + +
    C
    + +``` + +The following loop: + +```html +
    + A +
    +
    + Nothing here +
    +``` + +Will be compiled to: + +```html + + $item) { ?> +
    A
    + + + Nothing here + +``` + +### Combined control structures + +Control structures can be combined and will be parsed in order: + +```html +
    + +
    +``` + +### Echoing data + +The `{{ $var }}` and `{!! $raw !!}` expressions can be used to write out escaped and raw data respectively. Anything within these expressions is interpreted as PHP: + +```html +{{ strtoupper($var) }} +{!! $markdown->render($content) !!} +{{ uri([PostController::class, 'show'], post: $post->id) }} +``` + +### Comments + +The `{html}{{-- --}}` expression is used to mark a block of code as comments. These comments will be stripped out server-side and not passed to the frontend. Normal HTML `{html}` comments can be used as client-side comments. + +### Imports + +Tempest will merge all imports at the top of the compiled view, meaning that each view can import any reference it needs: + +```html + + +{{ uri([PostController::class, 'show'], post: $post->id) }} +``` + +### View file resolution + +Tempest views can be returned from a controller with data passed into them via named arguments: + +```php +return view(__DIR__ . '/views/home.view.php', title: 'foo', description: 'bar'); +return view('./views/home.view.php', title: 'foo', description: 'bar'); +return view('views/home.view.php', title: 'foo', description: 'bar'); +``` + +Tempest will search for view files according to the following rules: + +- View files always end with `.view.php` +- First we check whether the view path as-is exists (absolute paths, eg. when using `__DIR__`) +- If not, we'll check whether the view file can be found relative to the controller's location +- If not, we'll search all discovery locations for the given path + +### View objects + +instead of using a `.view.php` file directly, developers can opt to create custom view objects. These objects implement the {b`\Tempest\View\View`} interface and expose their public properties and methods to their associated view: + +```php +use Tempest\View\View; +use Tempest\View\IsView; + +final class BookView implements View +{ + use IsView; + + public function __construct( + public string $title, + public Book $book, + ) { + $this->path = __DIR__ . '/books.view.php'; + } + + public function summarize(Book $book): string + { + return // … + } +} +``` + +```html +

    {{ $title }}

    + +
    + {{ $this->summarize($relatedBook) }} +
    +``` + +### Templates + +The built-in `{html}` element may be used as a placeholder when you want to use a directive without rendering an actual element in the DOM. + +```html + +
    {{ $post->title }}
    +
    +``` + +The example above will only render the child `div` elements: + +```html +
    Post A
    +
    Post B
    +
    Post C
    +``` + +### Boolean attributes + +The HTML specification describes a special kind of attributes called [boolean attributes](https://html.spec.whatwg.org/multipage/common-microsyntaxes.html#boolean-attribute). These attributes don't have a value, but indicate `true` whenever they are present. + +Using an expression attribute that return a boolean variable will follow the HTML specification, effectively not rendering the attribute if the value is `false`. + +```html + +``` + +Depending on whether `$selected` evaluates to `true` or `false`, the above example may or may not render the `selected` attribute. + +Apart from HTMLs boolean attributes, the same syntax can be used with any expression attribute as well: + +```html +
    + + + +``` + +## View components + +Both template inclusion and inheritance with tempest/view is handled with html components. Any view file starting with `x-` will be considered to be a view component. View components are written as normal HTML elements, but can pass server-side variables between them in the form of normal and expression attributes. + +### Registering view components + +To create a view component, create a `.view.php` file that starts with `x-`. These files are referred to as anonymous view components and are automatically discovered by Tempest. + +```html app/x-base.view.php + + + {{ $title }} — AirAcme + AirAcme + + + + + +``` + +### Using view components + +All views may include a views components. In order to do so, you may simply use a component's name as a tag, including the `x-` prefix: + +```html app/home.view.php + +
    + {{ $this->post->body }} +
    +
    +``` + +The example above demonstrates how to pass data to a component using an [expression attribute](#expression-attributes), as well as how pass elements as children if that component where the `` tag is used. + +### Attributes in components + +Attributes and [expression attributes](#expression-attributes) may be passed into view components. They work the same way as normal elements, and their values will be available in variables of the same name: + +```html home.view.php + + // ... + +``` + +```html x-base.view.php +// ... +{{ $title }} +``` + +Note that the casing of attributes will affect the associated variable name: + +- `{txt}camelCase` and `{txt}PascalCase` attributes will be converted to `$lowercase` variables +- `{txt}kebab-case` and `{txt}snake_case` attributes will be converted to `$camelCase` variables. + +:::info +The idiomatic way of using attributes is to always use `{txt}kebab-case`. +::: + +### Fallthrough attributes + +When `{html}class` and `{html}style` attributes are used on a view component, they will automatically be added to the root node, or merged with the existing attribute if it already exists. + +```html x-button.view.php + +``` + +The example above defines a button component with a default set of classes. Using this component and providing another set of classes will merge them together: + +```html index.view.php + +``` + +Similarly, the `id` attribute will always replace an existing `id` attribute on the root node of a view component. + +### Dynamic attributes + +An `$attributes` variable is accessible within view components. This variable is an array that contains all attributes passed to the component, except expression attributes. + +Note that attributes names use `{txt}kebab-case`. + +```html x-badge.view.php + + {{ $attributes['value'] }} + +``` + +### Using slots + +The content of components is often dynamic, depending on external context to be rendered. View components may define zero or more slot outlets, which may be used to render the given HTML fragments. + +```html x-button.view.php + +``` + +The example above defines a button component with default classes, and a slot inside. This component may be used like a normal HTML element, providing the content that will be rendered in the slot outlet: + +```html index.view.php + + + + Delete + +``` + +### Default slot content + +A view component's slot can define a default value, which will be used when a view using that component doesn't pass any value to it: + +```html x-component.view.php +
    + Fallback value + Fallback value for named slot +
    +``` + +```html + + + +``` + +### Named slots + +When a single slot is not enough, names can be attached to them. When using a component with named slot, you may use the `` tag with a `name` attribute to render content in a named outlet: + +```html x-base.view.php + + + + + + + + + +``` + +The above example uses a slot named `styles` in its `` element. The `` element has a default, unnamed slot. A view component may use `` and optionally refer to the `styles` slot using the syntax mentionned above, or simply provide content that will be injected in the default slot: + +```html index.view.php + + + + + + + +

    + Hello World +

    +
    +``` + +### Dynamic slots + +Within a view component, a `$slots` variable will always be provided, allowing you to dynamically access the named slots within the component. + +This variable is an instance of {`Tempest\View\Slot`}, with has a handful of properties: + +- `{php}$slot->name`: the slot's name +- `{php}$slot->content`: the compiled content of the slot +- `{php}$slot->attributes`: all the attributes defined on the slot +- `{php}$slot->{attribute}`: dynamically access an attribute defined on the slot + +For instance, the snippet below implements a tab component that accepts any number of tabs. + +```html x-tabs.view.php +
    +

    {{ $slot->name }}

    +

    {!! $slot->content !!}

    +
    +``` + +```html + + This is the PHP tab + This is the JavaScript tab + This is the HTML tab + +``` + +### Dynamic view components + +On some occasions, you might want to dynamically render view components, ie. render a view component whose name is determined at runtime. You can use the `{html}` element to do so: + +```html + + + +``` + +### View component scope + +View components act almost exactly the same as PHP's closures: they only have access to the variables you explicitly provide them, and any variable defined within a view component won't leak into the out scope. + +The only difference with normal closures is that view components also have access to view-defined variables as local variables. + +```html + + + + + +``` + +```php +/* View-defined data will be available within the component directly */ +final class HomeController +{ + #[Get('/')] + public function __invoke(): View + { + return view('', siteTitle: 'Tempest'); + } +} +``` + +```html x-base.view.php +

    {{ $siteTitle }}

    +``` + +## Built-in components + +Besides components that you may create yourself, Tempest provides a default set of useful built-in components to improve your developer experience. + +All meta-data about discovered view components can be retrieved via the hidden `meta:view-component` command. + +```console +./tempest meta:view-component [view-component] +``` + +```json +{ + "file": "/…/tempest-framework/packages/view/src/Components/x-markdown.view.php", + "name": "x-markdown", + "slots": [], + "variables": [ + { + "type": "string|null", + "name": "$content", + "attributeName": "content", + "description": "The markdown content from a variable" + } + ] +} +``` + +### `x-base` + +A base template you can install into your own project as a starting point. This one includes the Tailwind CDN for quick prototyping. + +```html + +

    Welcome!

    +
    +``` + +### `x-form` + +This component provides a form element that will post by default and includes the csrf token out of the box: + +```html + + + + + +``` + +### `x-input` + +A versatile input component that will render labels and validation errors automatically. + +```html + + + +``` + +### `x-submit` + +A submit button component that prefills with a "Submit" label: + +```html + + +``` + +### `x-csrf-token` + +Includes the CSRF token in a form + +```html +
    + + +``` + +### `x-icon` + +This component provides the ability to inject any icon from the [Iconify](https://iconify.design/) project in your templates. + +```html + +``` + +The first time a specific icon is being rendered, Tempest will query the [Iconify API](https://iconify.design/docs/api/queries.html) to fetch the corresponding SVG tag. The result of this query will be cached indefinitely, so it can be reused at no further cost. + +:::info +Iconify has a large collection of icon sets, which you may browse using the [Icônes](https://icones.js.org/) directory. +::: + +### `x-vite-tags` + +Tempest has built-in support for [Vite](https://vite.dev/), the most popular front-end development server and build tool. You may read more about [asset bundling](../2-features/05-asset-bundling.md) in the dedicated documentation. + +This component simply inject registered entrypoints where it is called. + +```html x-base.view.php + + + + + + +``` + +Optionally, it accepts an `entrypoint` attribute. If it is passed, the component will not inject other entrypoints discovered by Tempest. + +```html x-base.view.php + +``` + +### `x-template` + +See [Templates](#templates). + +### `x-slot` + +See [Using slots](#using-slots). + +### `x-markdown` + +A component that will render markdown contents: + +```html +# hi + +``` + +### `x-component` + +A reserved component to render dynamic view components: + +```html + + Content + +``` + +The attributes and content of dynamic components are passed to the underlying component. + +## Possible IDE integrations + +This section lists a bunch of ideas for IDE features that would be useful for IDE integrations. + +### Click-through view files + +Clicking a view file path leads to the view: + +```php +return view(__DIR__ . '/views/home.view.php'); +return view('views/home.view.php'); +``` + +### View data autocompletion: + +```php +return view(__DIR__ . '/views/home.view.php', foo: 'Foo', bar: 'Bar'); +``` + +`$foo` and `$bar` are available as variables within `__DIR__ . '/views/home.view.php'`. + +```php +return view(__DIR__ . '/views/home.view.php', book: new Book(/* … */)); +``` + +`$book` is available in the view, and its type known for autocompletion. + +### Auto-import symbols + +Referencing a symbol within a view will automatically import it at the top of the file. + +```html + + +{{ uri([PostController::class, 'show'], post: $post->id) }} +``` + +### Loop variable autocompletion + +```html +
    + {{ $item }} {{-- Autocomplete here --}} +
    +``` + +### View component autocompletion + +```html + + +{{-- `$title` is available in the `x-book` component --}} +``` + +### Click-through view components + +cmd/ctrl+click on a view component's tag will open the associated view component file. + +### Auto-comment selected text + +```html +{{-- this text was selected then commented out via a keyboard shortcut --}} +``` + +### Cycle between comment types + +Pressing the same keyboard short twice will toggle between server-side and client-side comments + +```html +{{-- this text was selected then commented out via a keyboard shortcut --}} — First press + — Second press +this text was selected then commented out via a keyboard shortcut — Third press, reverts back to normal +``` diff --git a/docs/5-extra-topics/00-roadmap.md b/docs/5-extra-topics/00-roadmap.md new file mode 100644 index 000000000..e68b5f112 --- /dev/null +++ b/docs/5-extra-topics/00-roadmap.md @@ -0,0 +1,29 @@ +--- +title: Roadmap +--- + +Tempest's first stable version is now released! You're more than welcome to [contribute to Tempest](https://github.com/tempestphp/tempest-framework), and can even work on features in future milestones if anything is of particular interest to you. The best way to get in touch about Tempest development is to [join our Discord server](https://discord.gg/pPhpTGUMPQ). + +## Experimental features + +Given the size of the project, we decided to mark a couple of features as experimental. These features may still change without having to tag a new major release. Our goal is to rid all experimental components before Tempest 2.0. Here's the list of experimental features: + +- [tempest/view](/main/essentials/views): you can use both [Twig](/main/essentials/views#using-twig) or [Blade](/main/essentials/views#using-blade) as alternatives. +- [The command bus](/main/essentials/console-commands): you can plug in any other command bus if you'd like. +- [Authentication and authorization](/main/features/authentication): the current implementation is very lightweight, and we welcome people to experiment with more complex implementations as third-party packages before committing to a framework-provided solution. +- [ORM](/main/essentials/database): you can use existing ORMs like [Doctrine](https://www.doctrine-project.org/) as an alternative. +- [The DateTime component](https://github.com/tempestphp/tempest-framework/tree/main/packages/datetime): you can use [Carbon](https://carbon.nesbot.com/docs/) or [Psl](https://github.com/azjezz/psl) as alternatives. +- [The mail component](/docs/features/mail): this is a newly added component in Tempest 1.4, and is kept experimental for a couple of feature releases to make sure we can fix all edge cases before calling it "stable". +- The cryptography component: this is also kept experimental for a couple of feature releases to be able to iterate on the API. + +Please note that we're committed to making all of these components stable as soon as possible. To do so, we will need real-life feedback from the community. By marking these components as experimental, we acknowledge that we probably won't get it right from the get-go, and we want to be clear about that up front. + +## Upcoming features + +Apart from experimental features, we're also aware that Tempest isn't feature-complete yet. Below is a list of items in our priority list. Feel free to contact us via [GitHub](https://github.com/tempestphp/tempest-framework) or [Discord](https://tempestphp.com/discord) if you'd like to suggest other features, or want to help out with one of these: + +- Dedicated support for API development +- HTMX support combined with tempest/view +- Form builder +- Event bus and command bus improvements (transport support, async messaging, event sourcing, …) +- Queuing and messaging components diff --git a/docs/5-extra-topics/01-package-development.md b/docs/5-extra-topics/01-package-development.md new file mode 100644 index 000000000..911c3a1e3 --- /dev/null +++ b/docs/5-extra-topics/01-package-development.md @@ -0,0 +1,137 @@ +--- +title: Package development +description: "Tempest comes with a handful of tools to help third-party package developers." +--- + +## Overview + +Creating a package for Tempest is as simple as adding `tempest/core` as a dependency. When this happens, [discovery](../4-internals/02-discovery.md) will find the package thanks to composer metadata and register discoverable classes. + +Unlike Symfony or Laravel, Tempest doesn't have a dedicated "service provider" concept. Instead, you're encouraged to rely on [discovery](../4-internals/02-discovery.md) and [initializers](../1-essentials/05-container#dependency-initializers). + +## Preventing discovery + +You may create classes which would normally be discovered by Tempest. You may prevent this behavior by marking them with the {`Tempest\Discovery\SkipDiscovery`} attribute. + +You may still use that class internally, or allow you package to publish it using an [installer](#installers). + +```php +use Tempest\Discovery\SkipDiscovery; + +#[SkipDiscovery] +final readonly class UserMigration implements Migration +{ + // … +} +``` + +## Installers + +An installer is a command that publishes files to the user's project. For instance, this can be used to export migration files that shouldn't be discovered unless the user have published them. + +You may create an installed by implementing the {`Tempest\Core\Installer`} interface. Usually, the {`Tempest\Core\PublishesFiles`} trait is used to help with this task. This trait provides a convenient way to publish files and adjust their imports automatically. + +### Publishing files + +The `publish()` method from the {b`Tempest\Core\PublishesFiles`} trait allows for copying a file to the user's project. It will automatically adjust the file's imports, so that they point to the correct namespace. + +The user will have a chance to specify the destination of the file, and whether or not to overwrite it. + +```php +use Tempest\Core\Installer; +use Tempest\Core\PublishesFiles; +use Tempest\Discovery\SkipDiscovery; +use Tempest\Generation\ClassManipulator; +use function Tempest\src_namespace; +use function Tempest\src_path; + +final readonly class AuthInstaller implements Installer +{ + use PublishesFiles; + + public function getName(): string + { + return 'auth'; + } + + public function install(): void + { + $publishFiles = [ + __DIR__ . '/User.php' => src_path('User.php'), + __DIR__ . '/UserMigration.php' => src_path('UserMigration.php'), + __DIR__ . '/Permission.php' => src_path('Permission.php'), + __DIR__ . '/PermissionMigration.php' => src_path('PermissionMigration.php'), + __DIR__ . '/UserPermission.php' => src_path('UserPermission.php'), + __DIR__ . '/UserPermissionMigration.php' => src_path('UserPermissionMigration.php'), + ]; + + foreach ($publishFiles as $source => $destination) { + $this->publish( + source: $source, + destination: $destination, + ); + } + + $this->publishImports(); + } +} +``` + +### Customizing the publishing process + +You may provide a callback to the `publish()` method to customize the publishing process. This callback will be called after the file has been copied, but before the imports have been adjusted. + +```php +public function install(): void +{ + // … + + $this->publish( + source: $source, + destination: $destination, + callback: function (string $source, string $destination): void { + // … + }, + ); + + $this->publishImports(); +} +``` + +### Ensuring correct imports + +When publishing files using the `publish()` method, namespaces are not updated automatically. + +This needs to be done by calling the `publishImports()` method. This method will loop over all published files, and adjust any import that references published files. + +## Provider classes + +Unlike Symfony or Laravel, Tempest doesn't have a dedicated "service provider" concept. Instead, you're encouraged to rely on discovery and initializers. However, there might be situations where you need to set up things for your package. + +In order to do that, you may register a listener for the `KernelEvent::BOOTED` event. This event is triggered when Tempest's kernel has booted, but before any application code is run. It's the perfect place to hook into Tempest's internals if you need to set up stuff specifically for your package. + +```php +use Tempest\Core\KernelEvent; +use Tempest\EventBus\EventHandler; + +final readonly class MyPackageProvider +{ + public function __construct( + // You can inject any dependency you like + private Container $container, + ) {} + + #[EventHandler(KernelEvent::BOOTED)] + public function initialize(): void + { + // Do whatever needs to be done + $this->container->… + } +} +``` + +## Testing helpers + +Tempest provides a {`\Tempest\Framework\Testing\IntegrationTest`} class, which your PHPUnit tests may extend from. By doing so, your tests will automatically boot the framework, and have a range of helper methods available. + +For more information regarding testing, you may read the [dedicated documentation](../1-essentials/07-testing.md). diff --git a/docs/5-extra-topics/02-standalone-components.md b/docs/5-extra-topics/02-standalone-components.md new file mode 100644 index 000000000..17598d569 --- /dev/null +++ b/docs/5-extra-topics/02-standalone-components.md @@ -0,0 +1,164 @@ +--- +title: Standalone components +--- + +## Overview + +Many Tempest components can be installed as standalone packages in existing or new projects: `tempest/console`, `tempest/http`, `tempest/event-bus`, `tempest/debug`, `tempest/command-bus`, etc. + +Note that Tempest is in its early stages—some components still depend on `tempest/core`, while they ideally should not. This may change in the future. + +## `tempest/console` + +``` +composer require tempest/console +``` + +`tempest/console` ships with a built-in binary: + +```console +./vendor/bin/tempest + +

    Tempest

    + + +``` + +Or you can manually boot the console application like so: + +```php +run(); +``` + +## `tempest/http` + +`tempest/http` contains all code to run a web application: router and view renderer, controllers, HTTP exception handling, view components, etc. + +``` +composer require tempest/http +``` + +Note that `tempest/console` is shipped with `tempest/http` as well so that you can manage discovery cache, static pages, debug routes, use the local dev server, etc. + +You can install the necessary files with the built-in tempest console: + +```console +./vendor/bin/tempest install framework +``` + +Or you can manually create an `index.php` file in your project's public folder: + +```php +run(); +``` + +Note that the `root` path passed in `HttpApplication::boot` should point to your project's root folder. + +## `tempest/container` + +`tempest/container` is Tempest's standalone container implementation. Note that this package doesn't provide discovery, so initializers will need to be added manually. + +``` +composer require tempest/container +``` + +```php +$container = new Tempest\Container\GenericContainer(); + +$container->addInitializer(FooInitializer::class); + +$foo = $container->get(Foo::class); +``` + +## `tempest/debug` + +`tempest/debug` provides the `lw`, `ld` and `ll` functions. This package is truly standalone, but when installed in a Tempest project, it will also automatically write to configured log files. + +``` +composer require tempest/debug +``` + +```php +ld($variable); +``` + +## `tempest/view` + +Tempest's view renderer can be used to render views. + +``` +composer require tempest/view +``` + +```php +$container = Tempest::boot(__DIR__); + +$view = view(__DIR__ . '/src/b.view.php'); + +echo $container->get(ViewRenderer::class)->render($view); +``` + +## `tempest/event-bus` + +Tempest's event bus can be used as a standalone package, in order for event handlers to be discovered, you'll have to boot Tempest's kernel and resolve the event bus from the container: + +``` +composer require tempest/event-bus +``` + +```php +$container = Tempest::boot(); + +// You can manually resolve the event bus from the container +$eventBus = $container->get(\Tempest\EventBus\EventBus::class); +$eventBus->dispatch(new MyEvent()); + +// Or use the `event` function, which is shipped with the package +\Tempest\event(new MyEvent()); +``` + +## `tempest/command-bus` + +Tempest's event bus can be used as a standalone package, in order for event handlers to be discovered, you'll have to boot Tempest's kernel and resolve the event bus from the container: + +``` +composer require tempest/command-bus +``` + +```php +$container = Tempest::boot(); + +// You can manually resolve the command bus from the container +$commandBus = $container->get(\Tempest\CommandBus\CommandBus::class); +$commandBus->dispatch(new MyCommand()); + +// Or use the `command` function, which is shipped with the package +\Tempest\command(new \Brendt\MyEvent()); +``` + +## `tempest/mapper` + +`tempest/mapper` maps data between many types of sources, from arrays to objects, objects to JSON, … + +``` +composer require tempest/mapper +``` + +```php +Tempest::boot(); + +$foo = map(['name' => 'Hi'])->to(Foo::class); +``` diff --git a/docs/5-extra-topics/03-contributing.md b/docs/5-extra-topics/03-contributing.md new file mode 100644 index 000000000..16e9f5597 --- /dev/null +++ b/docs/5-extra-topics/03-contributing.md @@ -0,0 +1,280 @@ +--- +title: Contributing +keywords: "How do I" +--- + +Welcome aboard! We're excited that you are interested in contributing to the Tempest framework. We value all contributions to the project and have assembled the following resources to help you get started. Thanks for being a contributor! + +## Report an error or bug + +To report an error or a bug, please: + +- Head over to the [issue page](https://github.com/tempestphp/tempest-framework/issues) to open an issue. +- Provide as much context about the problem you are running into and the environment you are running Tempest in. +- Provide the version and, if relevant, the component you are running into issues with. +- For a shot at getting our "Perfect Storm" label, submit a PR with a failing test! + +Once the issue has been opened, the Tempest team will: + + + +- Label the issue appropriately. +- Assign the issue to the appropriate team member. +- Try and get a response to you as quickly as possible. + +In the event that an issue is opened, but we get no response within 30 days, the issue will be closed. + +## Request a feature + +Tempest is a work in progress. We recognize that some features you might benefit from or expect may be missing. If you do have a feature request, please: + +- Head over to the [issue page](https://github.com/tempestphp/tempest-framework/issues) to open an issue. +- Provide as much detail about the feature you are looking for and how it might benefit you and others. + +Once the feature request has been opened, the Tempest team will: + + + +- Label the issue appropriately. +- Ask any clarifying question to help better understand the use case. +- If the feature requested is accepted, the Tempest team will assign the `{txt}Uncharted waters` label. A Tempest team member or a member of the community can contribute the code for this. + +:::tip +We welcome all contributions and greatly value your time and effort. To ensure your work aligns with Tempest's vision and avoids unnecessary effort, we aim to provide clear guidance and feedback throughout the process. +::: + +## Contribute documentation + +Documentation is how users learn about the framework, and developers begin to understand how Tempest works under the hood. It's critical to everything we do! Thank you in advance for your assistance in ensuring Tempest documentation is extensive, user-friendly, and up-to-date. + +:::tip +We welcome contributions of any size! Feel free to submit a pull request, even if it's just fixing a typo or adding a sentence. We especially value additions coming from new users' perspectives, which help make Tempest more accessible. +::: + +To contribute to Tempest's documentation, please: + +- Head over to the [Tempest docs repository](https://github.com/tempestphp/tempest-docs) to fork the project. +- Add or edit any relevant documentation in a manner consistent with the rest of the documentation. +- Re-read what you wrote and run it through a spell checker. +- Open a pull request with your changes. + +Once a pull request has been opened, the Tempest team will: + +- Use GitHub reviews to review your pull request. +- If necessary, ask for revisions. +- If we decide to pass on your pull request, we will thank you for your contribution and explain our decision. We appreciate all the time contributors put into Tempest! +- If your pull request is accepted, we will mark it as such and merge it into the project. It will be released in the next tagged version! 🎉 + +## Contribute code + +So, you want to dive into the code. To make the most of your time, please ensure that any contributions pertain to an approved feature request or a confirmed bug. This helps us focus on the vision for Tempest and ensuring the best developer experience. + +To contribute to Tempest's code, you will need to first [setup Tempest locally](#setting-up-tempest-locally). Then, + +- Make the relevant code changes. +- Write tests that verify that your contribution works as expected. +- Run `composer qa` to ensure you are adhering to our style guidelines. +- Create a [pull request](https://github.com/tempestphp/tempest-framework/pulls) with your changes. +- If your pull request is connected to an open issue, add a line in your description that says `{txt}Fixes #xxx`, where `{txt}#xxx` is the number of the issue you're fixing. + +:::tip Pull request titles +We use [conventional commits](#commit-and-merge-conventions) to automatically generate readable changelogs. You may help with this by providing a clear pull request title, which will appear in the changelog and needs to be understandable without the pull request's content as a context. Read more about this in the [pull requests](#pull-requests) section. +::: + +Once a pull request has been opened, the Tempest team will: + +- Use GitHub reviews to review your pull request. +- Ensure all CI pipelines are passing. +- If necessary, ask for revisions. +- If we decide to pass on your pull request, we will thank you for your contribution and explain our decision. We appreciate all the time contributors put into Tempest! +- If your pull request is accepted, we will mark it as such and merge it into the project. It will be released in the next tagged version! 🎉 + +### Setting up Tempest locally + +- Install PHP. +- Install Composer. +- Install [Bun](https://bun.sh) or Node. +- [Fork and clone](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) the Tempest repository. + +In your terminal, run: + +```sh +cd /path/to/your/clone +composer update +bun install +bun dev +``` + +You're ready to get started! + +#### Linking your local Tempest to another local Tempest application + +If you have another Tempest application with which you want to use your local version of the framework, you may do so with [composer symlinking](https://getcomposer.org/doc/05-repositories.md#path). + +Add the following in your `composer.json`, replacing `{txt}/path/to/your/clone` with the absolute path to your local version of the framework: + +```json +{ + // ... + "repositories": [ + { + "type": "path", + "url": "/path/to/your/clone" + } + ], + "minimum-stability": "dev", + "prefer-stable": true + // ... +} +``` + +You may then run `{sh}composer require "tempest/framework:*"`. + +If you are also working on one of the JavaScript packages, you may also symlink them to your local Tempest application by running `{sh}bun install /path/to/your/clone/package`. Note that the path must be to the actual JavaScript package, and not the root of the framework. + +For instance, assuming you cloned the framework in `/Users/you/Code/forks/tempest`, the command to symlink `vite-plugin-tempest` should look like that: + +```sh +bun install /Users/you/Code/forks/tempest/packages/vite-plugin-tempest +``` + +Do not forget to run `bun dev` in the root of your local version of the framework, so your changes can be reflected on your local application without needing to run `bun build` each time. + +### Code style and conventions + +Tempest uses a modified version of PSR-12. We automate the entire styling process because we know everyone is used to different standards and workflows. To see some of the rules we enforce, check out our [Mago](https://github.com/tempestphp/tempest-framework/blob/main/mago.toml) and [Rector](https://github.com/tempestphp/tempest-framework/blob/main/rector.php) configurations. + +The following outlines some other guidelines we have established for Tempest. + +#### `final` and `readonly` as a default + +Whenever possible, classes should be `final` and `readonly`. This practice promotes immutability and prevents inadvertent changes to logic. + +:::tip{tabler:bulb} +You may watch this [video](https://www.youtube.com/watch?v=HiD6CwWq5Ds&ab_channel=PHPAnnotated) to understand {x:brendt_gd}'s thoughts about using `final`. +::: + +--- + +#### Acronym casing + +Tempest uses a modified version of the [.NET best practices](https://learn.microsoft.com/en-us/previous-versions/dotnet/netframework-4.0/ms229043(v=vs.100)?redirectedfrom=MSDN) for acronym casing. Please see below for our guidelines: + +**Do capitalize all characters of two to three character acronyms, except the first word of a camel-cased identifier.** +A class named `IPAddress` is an example of a short acronym (IP) used as the first word of a Pascal-cased identifier. A parameter named `ipAddress` is an example of a short acronym (ip) used as the first word of a camel-cased identifier. + +**Do capitalize only the first character of acronyms with four or more characters, except the first word of a camel-cased identifier.** +A class named `UuidGenerator` is an example of a long acronym (Uuid) used as the first word of a Pascal-cased identifier. A parameter named `uuidGenerator` is an example of a long acronym (uuid) used as the first word of a camel-cased identifier. + +**Do not capitalize any of the characters of any acronyms, whatever their length, at the beginning of a camel-cased identifier.** +A class named `Uuid` is an example of a long acronym (Uuid) used as the first word of a camel-cased identifier. A parameter named `dbUsername` is an example of a short acronym (db) used as the first word of a camel-cased identifier. + +--- + +#### Validation classes + +When writing error messages for validation rules, **refrain from including ending punctuation** such as periods, exclamation marks, or question marks. This helps in maintaining a uniform style and prevents inconsistency in error message presentation. + +```diff +- Value should be a valid email address! ++ Value should be a valid email address +``` + +--- + +#### Exception classes + +Exception classes can be thought of as events and should be named accordingly. Use a subject-verb structure in the past tense to describe what happened (e.g., `DatabaseOperationFailed`, `StorageUsageWasForbidden`, `AuthenticatedUserWasMissing`). + +- Do not suffix class names with `Exception`; the context of `throw` or `catch` language constructs makes their purpose clear. +- All exception classes must extend PHP's built-in `\Exception`. +- When appropriate, define marker interfaces such as `CacheException`, `DatabaseException`, or `FilesystemException` to group related exceptions. These interfaces must be suffixed with `Exception`. +- Set the exception message within the exception class itself—not where it is thrown. +- Override the constructor to only accept relevant context-specific input. + +For instance, the following exception accepts a the relevant cache key as a constructor argument, and keeps it accessible through a public property: + +```php LockAcquisitionTimedOut.php +final class LockAcquisitionTimedOut extends Exception implements CacheException +{ + public function __construct( + public readonly string $key, + ) { + parent::__construct("Lock with key `{$key}` could not be acquired on time."); + } +} +``` + +## Release workflow + +Tempest uses sub-splits to allow components to be installed as individual packages. The following outlines how this process works. + +### Workflow steps + +1. **Trigger event** + - When a pull request is merged, or a new tag is created, the `.github/workflows/subsplit-packages.yml` action is run. + +2. **Package information retrieval** + - When the `subsplit-packages.yml` is run, it calls `bin/get-packages`. + - This PHP script uses a combination of Composer and the filesystem to return (in JSON) some information about every package. It returns the: + - **Directory** + - **Name** + - **Package** + - **Organization** + - **Repository** + +3. **Action matrix creation** + - The result of the `get-packages` command is then used to create an action matrix. + - This ensures that the next steps are performed for _every_ package discovered. + +4. **Monorepo split action** + - The `symplify/monorepo-split-github-action@v2.3.0` GitHub action is called for every package and provided the necessary information (destination repo, directory, etc.). + - This action takes any changes and pushes them to the sub-split repository determined by combining the "Organization" and "Repository" values returned in step 2. + - Depending on whether a tag is found or not, a tag is also supplied so the repository is tagged appropriately. + +## Commit and merge conventions + +Commits must all respect the [conventional commit specification](https://www.conventionalcommits.org/en/), so the changelog and release notes are generated using the commit history. + +### Commit descriptions + +Commit descriptions **should not** start with an uppercase letter and should use [imperative mood](https://git.kernel.org/pub/scm/git/git.git/tree/Documentation/SubmittingPatches?h=v2.36.1#n181): + +```diff +- feat(support): Adds some cool feature ++ feat(support): add some cool feature +``` + +### Commit scopes + +Scopes are not mandatory, but are highly recommended for consistency and easy of read. The following scopes are the most commonly used: + +- `feat` — for a new feature +- `fix` — for a bug fix +- `refactor` — for changes in code that are neither bug fixes or new features +- `docs` — for any change related to the documentation +- `perf` — for code refactoring that improves performance +- `test` — for code related to automatic testing +- `style` — for refactoring related to the code style (not for CSS) +- `ci` — for changes related to our continuous integration pipeline +- `chore` — for anything else + +Here are some commit examples: + +``` +{:hl-property:feat:}({:hl-keyword:support:}): add `StringHelper` class +{:hl-property:feat:}({:hl-keyword:support/string:}): add `uuid` method +{:hl-property:perf:}({:hl-keyword:discovery:}): improve cache efficiency +{:hl-property:refactor:}({:hl-keyword:highlight:}): improve code readability +{:hl-property:docs:}: mention new `highlight` package +{:hl-property:chore:}: update dependencies +{:hl-property:style:}: apply php-cs-fixer +``` + +### Pull requests + +Pull request titles and descriptions should be as explicit as possible to ease the review process. + +Contributors are not required to respect conventional commits within pull requests, but doing so will ease the review process by removing some overhead for core contributors. + +All pull requests will be renamed to the conventional commit convention if necessary before being squash-merged to keep the commit history and changelog clean.