diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000000..a329b727c6
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1,49 @@
+/.github/ @brendt @innocenzi @aidan-casey
+/.github/workflows/coding-conventions.yml @aidan-casey
+/.github/workflows/create-gh-release.yml @innocenzi
+/.github/workflows/deploy-docs.yml @innocenzi
+/.github/workflows/integration-tests.yml @aidan-casey
+/.github/workflows/integration-tests-windows.yml @aidan-casey @brendt
+/.github/workflows/isolated-tests.yml @aidan-casey
+/.github/workflows/lint-pr-title.yml @innocenzi
+/.github/workflows/publish-javascript-packages.yml @innocenzi
+/.github/workflows/subsplit-packages.yml @aidan-casey
+/.github/workflows/trigger-tempest-app-qa.yml @brendt
+/.github/workflows/trigger-tempest-clean-qa.yml @brendt
+/.github/workflows/validate-packages.yml @aidan-casey
+/src/ @brendt
+/docs/ @brendt @innocenzi
+/packages/auth/ @innocenzi
+/packages/cache/ @innocenzi
+/packages/clock/ @aidan-casey
+/packages/command-bus/ @brendt @aidan-casey
+/packages/console/ @brendt
+/packages/container/ @brendt
+/packages/core/ @brendt
+/packages/cryptography/ @innocenzi
+/packages/database/ @brendt @innocenzi
+/packages/datetime/ @innocenzi
+/packages/debug/ @innocenzi
+/packages/discovery/ @brendt @aidan-casey
+/packages/event-bus/ @brendt @aidan-casey
+/packages/generation/ @brendt
+/packages/http/ @brendt @aidan-casey
+/packages/http-client/ @aidan-casey
+/packages/icon/ @innocenzi
+/packages/kv-store/ @innocenzi
+/packages/log/ @brendt
+/packages/mail/ @innocenzi
+/packages/mapper/ @brendt
+/packages/process/ @innocenzi
+/packages/reflection/ @brendt @aidan-casey
+/packages/router/ @brendt @aidan-casey
+/packages/router/src/Exceptions/local @innocenzi
+/packages/storage/ @innocenzi
+/packages/support/ @innocenzi @brendt
+/packages/upgrade/ @brendt
+/packages/validation/ @brendt @innocenzi
+/packages/view/ @brendt
+/packages/vite/ @innocenzi
+/packages/vite-plugin-tempest/ @innocenzi
+*.ts @innocenzi
+*.vue @innocenzi
diff --git a/.github/workflows/coding-conventions.yml b/.github/workflows/coding-conventions.yml
index 0192386fc7..7f66d9f3fd 100644
--- a/.github/workflows/coding-conventions.yml
+++ b/.github/workflows/coding-conventions.yml
@@ -4,7 +4,6 @@ on:
pull_request:
workflow_dispatch:
-# CSFixer and Rector are temporarily disabled until they have proper PHP 8.4 support
jobs:
check-style:
name: Run style check
@@ -15,12 +14,12 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: 8.4
+ php-version: 8.5
coverage: none
- name: Install dependencies
run: |
- composer update --prefer-dist --no-interaction
+ composer update --prefer-dist --no-interaction --ignore-platform-reqs
composer mago:install-binary
- name: Run Mago
@@ -46,7 +45,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: 8.4
+ php-version: 8.5
coverage: none
- name: Install composer dependencies
@@ -65,7 +64,7 @@ jobs:
# - name: Setup PHP
# uses: shivammathur/setup-php@v2
# with:
-# php-version: 8.4
+# php-version: 8.5
# coverage: none
#
# - name: Install composer dependencies
diff --git a/.github/workflows/integration-tests-windows.yml b/.github/workflows/integration-tests-windows.yml
index aea44c2f11..c2875b06d5 100644
--- a/.github/workflows/integration-tests-windows.yml
+++ b/.github/workflows/integration-tests-windows.yml
@@ -16,7 +16,7 @@ jobs:
fail-fast: false
matrix:
php:
- - 8.4
+ - 8.5
os:
- windows-latest
env:
@@ -55,7 +55,7 @@ jobs:
os:
- windows-latest
php:
- - 8.4
+ - 8.5
database:
- sqlite
- mysql
@@ -84,7 +84,7 @@ jobs:
echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
- name: Install dependencies
- run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction
+ run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --ignore-platform-reqs
- name: "Setup Redis"
if: ${{ matrix.os != 'windows-latest' }}
diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml
index 48f2862f01..d9e3e67ff6 100644
--- a/.github/workflows/integration-tests.yml
+++ b/.github/workflows/integration-tests.yml
@@ -20,7 +20,7 @@ jobs:
fail-fast: false
matrix:
php:
- - 8.4
+ - 8.5
os:
- ubuntu-latest
# - windows-latest
@@ -59,7 +59,7 @@ jobs:
- ubuntu-latest
# - windows-latest
php:
- - 8.4
+ - 8.5
database:
- sqlite
- mysql
@@ -90,7 +90,7 @@ jobs:
echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
- name: Install dependencies
- run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction
+ run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction --ignore-platform-reqs
- name: "Setup Redis"
if: ${{ matrix.os != 'windows-latest' }}
diff --git a/.github/workflows/isolated-tests.yml b/.github/workflows/isolated-tests.yml
index e296c065dc..fe1da2c33d 100644
--- a/.github/workflows/isolated-tests.yml
+++ b/.github/workflows/isolated-tests.yml
@@ -17,7 +17,7 @@ jobs:
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
- php-version: 8.4
+ php-version: 8.5
coverage: none
- uses: actions/checkout@v4
@@ -43,7 +43,7 @@ jobs:
- ubuntu-latest
package: ${{ fromJson(needs.get_packages.outputs.matrix) }}
php:
- - 8.4
+ - 8.5
stability:
- prefer-stable
- prefer-lowest
@@ -66,7 +66,7 @@ jobs:
uses: supercharge/redis-github-action@1.7.0
- name: Install PHPUnit
- run: composer global require phpunit/phpunit:12.4.0
+ run: composer global require phpunit/phpunit:12.5.8
- name: Setup problem matchers
run: |
@@ -75,9 +75,9 @@ jobs:
- name: Install dependencies
run: |
- ./bin/build-changed-packages
+ ./bin/build-packages
cd "packages/${{ matrix.package.basename }}"
- composer update --${{ matrix.stability }} --prefer-dist --no-interaction
+ composer update --${{ matrix.stability }} --prefer-dist --no-interaction --ignore-platform-reqs
- name: Execute tests
run: phpunit -c "packages/${{ matrix.package.basename }}/phpunit.xml"
diff --git a/.github/workflows/subsplit-packages.yml b/.github/workflows/subsplit-packages.yml
index b534d3a304..5233d1d579 100644
--- a/.github/workflows/subsplit-packages.yml
+++ b/.github/workflows/subsplit-packages.yml
@@ -2,13 +2,13 @@ name: "Sub-split packages"
on:
push:
- branches: [main]
+ branches:
+ - "[0-9]+.x"
+ - "v[0-9]+.[0-9]+-alpha.[0-9]+"
+ - "v[0-9]+.[0-9]+-beta.[0-9]+"
tags: ["v*"]
workflow_dispatch:
-env:
- GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
-
jobs:
get_packages:
name: Get packages
@@ -17,17 +17,18 @@ jobs:
- name: Set Up PHP
uses: shivammathur/setup-php@v2
with:
- php-version: 8.4
+ php-version: 8.5
coverage: none
- uses: actions/checkout@v4
- name: Get packages
id: get_json
- run: echo "::set-output name=json::$(bin/get-packages)"
+ run: echo "json=$(bin/get-packages)" >> $GITHUB_OUTPUT
- name: Output packages
run: echo "${{ steps.get_json.outputs.json }}"
+
outputs:
matrix: ${{ steps.get_json.outputs.json }}
@@ -41,34 +42,11 @@ jobs:
package: ${{ fromJson(needs.get_packages.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
- # no tag
- - if: "!startsWith(github.ref, 'refs/tags/')"
- uses: "symplify/monorepo-split-github-action@v2.3.0"
- with:
- # ↓ split "packages/console" directory
- package_directory: "${{ matrix.package.directory }}"
-
- # ↓ into https://github.com/tempestphp/tempest-console repository
- repository_organization: "${{ matrix.package.organization }}"
- repository_name: "${{ matrix.package.repository }}"
-
- # ↓ the user signed under the split commit
- user_name: "aidan-casey"
- user_email: "aidan@caseyhouse.net"
-
- # with tag
- - if: "startsWith(github.ref, 'refs/tags/')"
- uses: "symplify/monorepo-split-github-action@v2.3.0"
- with:
- tag: ${GITHUB_REF#refs/tags/}
-
- # ↓ split "packages/console" directory
- package_directory: "${{ matrix.package.directory }}"
- # ↓ into https://github.com/tempestphp/tempest-console repository
- repository_organization: "${{ matrix.package.organization }}"
- repository_name: "${{ matrix.package.repository }}"
+ - name: Set up git-filter-repo
+ run: pip install git-filter-repo
- # ↓ the user signed under the split commit
- user_name: "aidan-casey"
- user_email: "aidan@caseyhouse.net"
+ - name: Split Package
+ env:
+ GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
+ run: ./bin/split "${{ matrix.package.name }}"
diff --git a/.github/workflows/validate-packages.yml b/.github/workflows/validate-packages.yml
index 7a62fd23cc..dc7643f31b 100644
--- a/.github/workflows/validate-packages.yml
+++ b/.github/workflows/validate-packages.yml
@@ -18,7 +18,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: 8.4
+ php-version: 8.5
extensions: dom, curl, libxml, mbstring, pcntl, fileinfo, intl
coverage: none
diff --git a/bin/build-changed-packages b/bin/build-changed-packages
deleted file mode 100755
index 1f017bea1a..0000000000
--- a/bin/build-changed-packages
+++ /dev/null
@@ -1,56 +0,0 @@
-#!/usr/bin/env php
- [],
-];
-
-foreach ($tempestPackages as $package) {
- // Find out if there are changes in this package.
- $diff = exec(sprintf('git diff --name-only HEAD^ -- %s', $package['directory']));
-
- $composerPath = sprintf('%s/composer.json', $package['directory']);
- $composerFile = json_decode(file_get_contents($composerPath), true);
-
- // If there are changes, bundle the package and
- // add it to our root packages.json file.
- if (empty($diff) === false) {
- // Bundle the current package as a tar file.
- passthru(sprintf("cd %s && tar -cf package.tar --exclude='package.tar' *", $package['directory']));
-
- // TODO: Update the package version.
- $composerFile['version'] = 'dev-main';
- $composerFile['dist']['type'] = 'tar';
- $composerFile['dist']['url'] = 'file://'. $package['directory'] . '/package.tar';
-
- // Add the package details to the root "packages.json."
- $composerPackages['packages'][$composerFile['name']][$composerFile['version']] = $composerFile;
- }
-
- // Load the packages from the root "packages.json" file we will write in a second.
- $composerFile['repositories'] = [
- [
- 'type' => 'composer',
- 'url' => realpath(__DIR__ . '/../'),
- ]
- ];
-
- file_put_contents($composerPath, json_encode($composerFile, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
-}
-
-file_put_contents(__DIR__ . '/../packages.json', json_encode($composerPackages, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
-
-var_dump(file_get_contents(__DIR__ . '/../packages.json'));
\ No newline at end of file
diff --git a/bin/build-packages b/bin/build-packages
new file mode 100755
index 0000000000..3c68c4bb3c
--- /dev/null
+++ b/bin/build-packages
@@ -0,0 +1,62 @@
+#!/usr/bin/env php
+ $version) {
+ if (str_starts_with($dep, 'tempest/') && str_starts_with($version, 'dev-')) {
+ $devVersion = $version;
+ break 2;
+ }
+ }
+}
+
+$composerPackages = [
+ 'packages' => [],
+];
+
+foreach ($tempestPackages as $package) {
+ $composerPath = sprintf('%s/composer.json', $package['directory']);
+ $composerFile = json_decode(file_get_contents($composerPath), true);
+
+ // Bundle ALL packages as tar files to ensure consistent versions.
+ // This ensures all inter-package dependencies (e.g., dev-3.x) can be
+ // resolved from the local repository.
+ passthru(sprintf("cd %s && tar -cf package.tar --exclude='package.tar' *", $package['directory']));
+
+ $composerFile['version'] = $devVersion;
+ $composerFile['dist']['type'] = 'tar';
+ $composerFile['dist']['url'] = 'file://'. $package['directory'] . '/package.tar';
+
+ // Add the package details to the root "packages.json."
+ $composerPackages['packages'][$composerFile['name']][$composerFile['version']] = $composerFile;
+
+ // Load the packages from the root "packages.json" file we will write in a second.
+ $composerFile['repositories'] = [
+ [
+ 'type' => 'composer',
+ 'url' => realpath(__DIR__ . '/../'),
+ ]
+ ];
+
+ file_put_contents($composerPath, json_encode($composerFile, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+}
+
+file_put_contents(__DIR__ . '/../packages.json', json_encode($composerPackages, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+
+var_dump(file_get_contents(__DIR__ . '/../packages.json'));
diff --git a/bin/release b/bin/release
index 293575f1d5..0039c622e6 100755
--- a/bin/release
+++ b/bin/release
@@ -18,11 +18,10 @@ use Composer\Semver\VersionParser;
use Tempest\Console\Console;
use Tempest\Console\ConsoleApplication;
use Tempest\Console\Exceptions\InterruptException;
-use Tempest\Http\Response;
use Tempest\HttpClient\HttpClient;
use Tempest\Support\Json;
+use Tempest\Container;
-use function Tempest\get;
use function Tempest\Support\arr;
use function Tempest\Support\str;
@@ -87,9 +86,11 @@ function bumpPhpPackages(string $version, bool $isMajor): void
*/
function cleanUpAfterRelease(): void
{
- // We want to still be able to `require tempest/package:dev-main`, so we need
- // to update back all composer files to use `dev-main` instead of a fixed version.
- executeCommands('./vendor/bin/monorepo-builder bump-interdependency dev-main');
+ $devVersion = sprintf("%s-dev", getCurrentBranch());
+
+ // We want to still be able to `require tempest/package:X.x-dev`, so we need
+ // to update back all composer files to use the dev version instead of a fixed version.
+ executeCommands("./vendor/bin/monorepo-builder bump-interdependency {$devVersion}");
// Finds all `composer.json` files in `packages/`, and revert the `tempest/highlight` dependency to the saved version
setPhpDependencyVersion(
@@ -145,7 +146,7 @@ function updateChangelog(string $version): void
/**
* Ensure the release script can run.
*/
-function performPreReleaseChecks(string $remote, string $branch): void
+function performPreReleaseChecks(string $remote): void
{
if (empty(shell_exec('which bun'))) {
throw new Exception('This script requires `bun` to be installed.');
@@ -155,6 +156,12 @@ function performPreReleaseChecks(string $remote, string $branch): void
throw new Exception('Repository must be in a clean state to release.');
}
+ $branch = getCurrentBranch();
+
+ if (! preg_match('/^\d+\.x$/', $branch)) {
+ throw new Exception("You must be on a version branch to release. Current branch is {$branch}.");
+ }
+
if (! str_starts_with(shell_exec('git rev-parse --abbrev-ref --symbolic-full-name @{u}'), "{$remote}/{$branch}")) {
throw new Exception("You must be on the {$remote}/{$branch} branch to release.");
}
@@ -178,7 +185,7 @@ function updateBranchProtection(bool $enabled): void
$token = Tempest\env('RELEASE_GITHUB_TOKEN');
$uri = "https://api.github.com/repos/tempestphp/tempest-framework/rulesets/{$ruleset}";
- $httpClient = Tempest\get(HttpClient::class);
+ $httpClient = Container\get(HttpClient::class);
$response = $httpClient->put(
uri: $uri,
headers: ['Authorization' => "Bearer {$token}"],
@@ -196,7 +203,7 @@ function triggerSubsplit(): void
$token = Tempest\env('RELEASE_GITHUB_TOKEN');
$uri = 'https://api.github.com/repos/tempestphp/tempest-framework/actions/workflows/subsplit-packages.yml/dispatches';
- $httpClient = Tempest\get(HttpClient::class);
+ $httpClient = Container\get(HttpClient::class);
$response = $httpClient->post(
uri: $uri,
@@ -205,7 +212,7 @@ function triggerSubsplit(): void
'Accept' => 'application/vnd.github+json',
'X-GitHub-Api-Version' => '2022-11-28',
],
- body: Json\encode(['ref' => 'main']),
+ body: Json\encode(['ref' => getCurrentBranch()]),
);
if (! $response->status->isSuccessful()) {
@@ -222,6 +229,14 @@ function getCurrentVersion(): string
return exec('git describe --tags --abbrev=0');
}
+/**
+ * Gets the current git branch name.
+ */
+function getCurrentBranch(): string
+{
+ return trim(shell_exec('git rev-parse --abbrev-ref HEAD') ?? '');
+}
+
/**
* Suggests a semver-valid version.
*/
@@ -344,9 +359,9 @@ function ensureTagDoesNotExist(string $version): void
try {
ConsoleApplication::boot();
- performPreReleaseChecks('origin', 'main');
+ performPreReleaseChecks('origin');
- $console = get(Console::class);
+ $console = Container\get(Console::class);
$console->writeln();
$console->info(sprintf('Current version is %s.', $current = getCurrentVersion()));
diff --git a/bin/split b/bin/split
new file mode 100755
index 0000000000..2d2cc48e25
--- /dev/null
+++ b/bin/split
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+
+PACKAGE_NAME=$1
+SUBSPLIT_DIR="${PACKAGE_NAME}_subsplit"
+ORG_NAME="tempestphp"
+REPOSITORY_NAME="tempest-${PACKAGE_NAME}"
+
+# Check if the split repository exists, if not create it
+if ! gh repo view "${ORG_NAME}/${REPOSITORY_NAME}" &>/dev/null; then
+ gh repo create "${ORG_NAME}/${REPOSITORY_NAME}" \
+ --public \
+ --description "[READ ONLY] Sub split of the Tempest ${PACKAGE_NAME} component." \
+ --disable-issues \
+ --disable-wiki
+fi
+
+# Clone our Tempest repository and jump into it.
+git clone https://github.com/tempestphp/tempest-framework "$SUBSPLIT_DIR"
+cd "$SUBSPLIT_DIR"
+
+# Get the current default branch of the Tempest repo.
+DEFAULT_BRANCH=$(git branch --show-current)
+
+# Filter the history down to only the one package.
+git filter-repo --subdirectory-filter "packages/${PACKAGE_NAME}"
+
+# Setup our remote repository we are splitting to.
+git remote add origin "https://x-access-token:${GH_TOKEN}@github.com/${ORG_NAME}/${REPOSITORY_NAME}.git"
+git branch -m "$DEFAULT_BRANCH"
+
+# Push all branches + history to the repo.
+git push --force --all origin
+
+# Push all releases / tags to the repo.
+git push --force --tags origin
+
+# Cleanup
+cd ../
+rm -rf "${SUBSPLIT_DIR}"
diff --git a/composer.json b/composer.json
index 411d6500d0..6d2fa71066 100644
--- a/composer.json
+++ b/composer.json
@@ -24,7 +24,7 @@
"monolog/monolog": "^3.7.0",
"nette/php-generator": "^4.1.6",
"nikic/php-parser": "^5.3",
- "php": "^8.4",
+ "php": "^8.5",
"psr-discovery/http-client-implementations": "^1.4",
"psr-discovery/http-factory-implementations": "^1.2",
"psr/cache": "^3.0",
@@ -33,7 +33,7 @@
"psr/http-factory": "^1.0",
"psr/http-message": "^1.0|^2.0",
"psr/log": "^3.0.0",
- "rector/rector": "^2.2.5",
+ "rector/rector": "^2.3.2",
"symfony/cache": "^7.3",
"symfony/mailer": "^7.2.6",
"symfony/process": "^7.3",
@@ -51,7 +51,6 @@
"azure-oss/storage-blob-flysystem": "^1.2",
"brianium/paratest": "^7.14",
"carthage-software/mago": "1.0.0-beta.28",
- "depotwarehouse/oauth2-twitch": "^1.3",
"guzzlehttp/psr7": "^2.6.1",
"league/flysystem-aws-s3-v3": "^3.25.1",
"league/flysystem-ftp": "^3.25.1",
@@ -75,7 +74,7 @@
"phpat/phpat": "^0.11.0",
"phpbench/phpbench": "84.x-dev",
"phpstan/phpstan": "^2.0",
- "phpunit/phpunit": "^12.2.3",
+ "phpunit/phpunit": "^12.5.8",
"predis/predis": "^3.0.0",
"riskio/oauth2-auth0": "^2.4",
"smolblog/oauth2-twitter": "^1.0",
@@ -88,6 +87,7 @@
"tempest/blade": "dev-main",
"thenetworg/oauth2-azure": "^2.2",
"twig/twig": "^3.16",
+ "vertisan/oauth2-twitch-helix": "^2.0",
"wohali/oauth2-discord-new": "^1.2"
},
"replace": {
@@ -212,6 +212,7 @@
"Tempest\\Cryptography\\Tests\\": "packages/cryptography/tests",
"Tempest\\Database\\Tests\\": "packages/database/tests",
"Tempest\\DateTime\\Tests\\": "packages/datetime/tests",
+ "Tempest\\Debug\\Tests\\": "packages/debug/tests",
"Tempest\\EventBus\\Tests\\": "packages/event-bus/tests",
"Tempest\\Generation\\Tests\\": "packages/generation/tests",
"Tempest\\HttpClient\\Tests\\": "packages/http-client/tests",
@@ -249,11 +250,14 @@
"lint:fix": "vendor/bin/mago lint --fix --format-after-fix",
"style": "composer fmt && composer lint:fix",
"test": "composer phpunit",
+ "test:stop": "composer phpunit -- --stop-on-error --stop-on-failure",
"lint": "vendor/bin/mago lint --potentially-unsafe --minimum-fail-level=note",
"phpstan": "vendor/bin/phpstan analyse src tests --memory-limit=1G",
"rector": "vendor/bin/rector process --no-ansi",
"merge": "php -d\"error_reporting = E_ALL & ~E_DEPRECATED\" vendor/bin/monorepo-builder merge",
"intl:plural": "./packages/intl/bin/plural-rules.php",
+ "exceptions:dev": "cd ./packages/router/src/Exceptions/local && bun i && bun run dev",
+ "exceptions:build": "cd ./packages/router/src/Exceptions/local && bun i && bun run build",
"release": [
"composer qa",
"./bin/release"
@@ -267,7 +271,8 @@
"./bin/validate-packages",
"./tempest discovery:clear --no-interaction",
"composer phpunit",
- "composer phpstan"
+ "composer phpstan",
+ "composer exceptions:build"
]
}
}
diff --git a/docs/0-getting-started/02-installation.md b/docs/0-getting-started/02-installation.md
index fc0955a65c..6ab267cc3a 100644
--- a/docs/0-getting-started/02-installation.md
+++ b/docs/0-getting-started/02-installation.md
@@ -5,7 +5,7 @@ description: Tempest can be installed as a standalone PHP project, as well as a
## 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.
+Tempest requires PHP [8.5+](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.
@@ -24,7 +24,7 @@ If you have a dedicated development environment, you may then access your applic
```sh
{:hl-keyword:php:} tempest serve
-{:hl-comment:PHP 8.4.5 Development Server (http://localhost:8000) started:}
+{:hl-comment:PHP 8.5.1 Development Server (http://localhost:8000) started:}
```
### Scaffolding front-end assets
@@ -73,7 +73,7 @@ Tempest won't impose any file structure on you: one of its core features is that
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.
+This concept is called [discovery](../1-essentials/05-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:
@@ -98,9 +98,11 @@ The following project structures work the same way in Tempest, without requiring
## 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.
+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 caches the discovery process](../1-essentials/05-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]`:
+As an example, Tempest is able to determine which methods are controller methods based on their [route attributes](../1-essentials/01-routing.md), or to detect console commands based on methods annotated with {b`#[Tempest\Console\ConsoleCommand]`}:
+
+:::code-group
```php app/BlogPostController.php
use Tempest\Router\Get;
@@ -119,18 +121,22 @@ final readonly class BlogPostController
}
```
-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
- { /* … */ }
+ {
+ // …
+ }
}
```
+
+:::
+
+:::tip{tabler:link}
+Learn more about discovery in the [dedicated documentation](../1-essentials/05-discovery.md).
+:::
diff --git a/docs/1-essentials/01-routing.md b/docs/1-essentials/01-routing.md
index b6cc31b00f..9f52b3e793 100644
--- a/docs/1-essentials/01-routing.md
+++ b/docs/1-essentials/01-routing.md
@@ -5,21 +5,21 @@ description: "Learn how to route requests to controllers. In Tempest, this is do
## 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.
+In Tempest, routes can be associated with any class method. This is typically done in dedicated controller classes, but any class can be used.
-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.
+Tempest provides attributes, named after HTTP verbs, to attach URIs to controller actions. These attributes implement the {b`Tempest\Router\Route`} interface, allowing custom route attributes to be created.
```php app/HomeController.php
use Tempest\Router\Get;
use Tempest\View\View;
-use function Tempest\view;
+use function Tempest\View\view;
final readonly class HomeController
{
#[Get(uri: '/home')]
public function __invoke(): View
{
- return view('home.view.php');
+ return view('./home.view.php');
}
}
```
@@ -28,12 +28,12 @@ Out of the box, an attribute for every HTTP verb is available: {b`Tempest\Router
## 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.
+Dynamic segments can be defined in route URIs by wrapping them in curly braces. The segment name inside the braces is passed as a parameter to the controller method.
```php app/AircraftController.php
use Tempest\Router\Get;
use Tempest\View\View;
-use function Tempest\view;
+use function Tempest\View\view;
final readonly class AircraftController
{
@@ -44,21 +44,21 @@ final readonly class AircraftController
$aircraft = $this->aircraftRepository->getAircraftById($id);
// Pass the aircraft to the view
- return view('aircraft.view.php', aircraft: $aircraft);
+ return view('./aircraft.view.php', aircraft: $aircraft);
}
}
```
### Optional parameters
-Sometimes you may want a route to match both with and without a parameter. For instance, you might want `/aircraft` to show all aircraft, and `/aircraft/123` to show a specific aircraft. This can be achieved by marking route parameters as optional.
+A route can match both with and without a parameter. For instance, `/aircraft` can show all aircraft, while `/aircraft/123` shows a specific aircraft. This is achieved by marking route parameters as optional.
To mark a parameter as optional, prefix it with a question mark `?` inside the curly braces. The corresponding method parameter must either be nullable or have a default value.
```php app/AircraftController.php
use Tempest\Router\Get;
use Tempest\View\View;
-use function Tempest\view;
+use function Tempest\View\view;
final readonly class AircraftController
{
@@ -66,10 +66,8 @@ final readonly class AircraftController
public function index(?string $id): View
{
if ($id === null) {
- // Show all aircraft
$aircraft = $this->aircraftRepository->all();
} else {
- // Show specific aircraft
$aircraft = $this->aircraftRepository->find($id);
}
@@ -78,32 +76,32 @@ final readonly class AircraftController
}
```
-In this example, both `/aircraft` and `/aircraft/123` will match the same route. When the parameter is not provided, the method parameter receives `null`.
+In this example, both `/aircraft` and `/aircraft/123` match the same route. When the parameter is not provided, the method parameter receives `null`.
-Alternatively, you may provide a default value instead of using a nullable type:
+Alternatively, a default value can be provided instead of using a nullable type:
```php app/AircraftController.php
#[Get(uri: '/aircraft/{?type}')]
public function filter(string $type = 'all'): View
{
- // When /aircraft is requested, $type will be 'all'
- // When /aircraft/commercial is requested, $type will be 'commercial'
+ // $type defaults to 'all' when not provided
+ // $type is set to the provided value otherwise
}
```
-You may also combine required and optional parameters. Optional parameters should come after required ones:
+Required and optional parameters can be combined. Optional parameters must come after required ones:
```php app/FlightController.php
use Tempest\Router\Get;
use Tempest\View\View;
-use function Tempest\view;
+use function Tempest\View\view;
final readonly class FlightController
{
- #[Get(uri: '/flights/{id}/{?segment}')]
- public function show(string $id, ?string $segment): View
+ #[Get(uri: '/flights/{flightNumber}/{?segment}')]
+ public function show(string $flightNumber, ?string $segment): View
{
- // Matches both /flights/AA123 and /flights/AA123/departure
+ // Matches both /flights/JFA123 and /flights/JFA123/departure
}
}
```
@@ -114,11 +112,11 @@ Multiple optional parameters are also supported:
#[Get(uri: '/aircraft/{?manufacturer}/{?model}')]
public function search(?string $manufacturer, ?string $model): View
{
- // Matches /aircraft, /aircraft/cessna, and /aircraft/cessna/172
+ // Matches /aircraft, /aircraft/pilatus, and /aircraft/pilatus/pc24
}
```
-Optional parameters work seamlessly with [regular expression constraints](#regular-expression-constraints). Simply add the regex pattern after the parameter name:
+Optional parameters work with [regular expression constraints](#regular-expression-constraints). Add the regular expression after the parameter name:
```php app/AircraftController.php
#[Get(uri: '/aircraft/{?id:\d+}')]
@@ -130,14 +128,14 @@ public function show(?int $id): View
### Regular expression constraints
-You may constrain the format of a route parameter by specifying a regular expression after its name.
+The format of a route parameter can be constrained 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:
+For instance, to accept only numeric identifiers for an `id` parameter, use the following dynamic segment: `{regex}{id:[0-9]+}`. In practice, a route looks like this:
```php app/AircraftController.php
use Tempest\Router\Get;
use Tempest\View\View;
-use function Tempest\view;
+use function Tempest\View\view;
final readonly class AircraftController
{
@@ -151,7 +149,7 @@ final readonly class AircraftController
### 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.
+Controller actions can receive objects instead of scalar values such as identifiers. This is particularly useful for [models](./03-database.md#models) to avoid writing fetching logic in each controller.
```php app/AircraftController.php
use Tempest\Router\Get;
@@ -165,7 +163,7 @@ final class AircraftController
}
```
-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.
+Route binding can be enabled for any class that implements the {b`Tempest\Router\Bindable`} interface, which requires a static `resolve()` method responsible for returning the correct instance.
```php
use Tempest\Router\Bindable;
@@ -173,36 +171,47 @@ use Tempest\Database\IsDatabaseModel;
final class Aircraft implements Bindable
{
- use IsDatabaseModel;
-
public static function resolve(string $input): self
{
- return self::findById(id: $input);
+ return query(self::class)->resolve($input);
}
}
```
-By default, `Bindable` objects will be cast to strings when they are passed into the `uri()` function as a route parameter. You can override this default behaviour by tagging a public property on the object with the {b`\Tempest\Router\IsBindingValue`} attribute:
+By default, {b`Tempest\Router\Bindable`} objects are cast to strings when passed into the {b`Tempest\Router\uri()`} function as a route parameter. This means that these objects should implement `Stringable`.
-```php
+This default behaviour can be overridden by annotating a public property on the object with the {b`\Tempest\Router\IsBindingValue`} attribute:
+
+:::code-group
+
+```php app/Aircraft.php
use Tempest\Router\Bindable;
use Tempest\Router\IsBindingValue;
final class Aircraft implements Bindable
{
#[IsBindingValue]
- public string $callSign;
+ public string $registrationNumber;
public static function resolve(string $input): self
{
- return self::findById(id: $input);
+ return query(self::class)
+ ->where('registrationNumber', $input)
+ ->first();
}
}
```
+```php "URI generation"
+uri(ShowAircraftController::class, aircraft: $aircraft);
+// → /aircraft/lxjfa
+```
+
+:::
+
### 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.
+String-backed enumerations can be injected into controller actions. Tempest maps 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;
@@ -216,7 +225,7 @@ final readonly class AircraftController
}
```
-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.
+In the example above, an `AircraftType` enumeration is injected. If the request's `type` parameter has a value specified in that enumeration, it is passed to the controller action. Otherwise, an HTTP 404 response is returned without entering the controller method.
```php app/AircraftType.php
enum AircraftType: string
@@ -227,36 +236,24 @@ enum AircraftType: string
}
```
-### 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\Router\uri` function that can be used to generate a 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).
+Tempest provides a {b`\Tempest\Router\uri()`} function to generate URIs to controller methods. This function accepts the fully-qualified class name 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\Router\uri;
// Invokable classes can be referenced directly:
uri(HomeController::class);
-// /home
+// → /home
// Classes with named methods are referenced using an array
uri([AircraftController::class, 'store']);
-// /aircraft
+// → /aircraft
// Additional URI parameters are passed in as named arguments:
uri([AircraftController::class, 'show'], id: $aircraft->id);
-// /aircraft/1
+// → /aircraft/1
```
:::info
@@ -265,9 +262,9 @@ URI-related methods are also available by injecting the {b`Tempest\Router\UriGen
### Signed URIs
-A signed URI may be used to ensure that the URI was not modified after it was created. This is useful for implementing login links, or other endpoints that need protection against tampering.
+A signed URI ensures that the URI was not modified after it was created. This is useful for implementing login or unsubscribe links, or other endpoints that need protection against tampering.
-To create a signed URI, you may use the `signed_uri` function. This function accepts the same arguments as `uri`, and returns the URI with a `signature` parameter:
+To create a signed URI, use the {b`\Tempest\Router\signed_uri()`} function. This function accepts the same arguments as {b`\Tempest\Router\uri()`} and returns the URI with a `signature` parameter:
```php
use function Tempest\Router\signed_uri;
@@ -278,7 +275,7 @@ signed_uri(
);
```
-Alternatively, you may use `temporary_signed_uri` to provide a duration after which the signed URI will expire, providing an extra layer of security.
+Alternatively, {b`\Tempest\Router\temporary_signed_uri()`} can be used to provide a duration after which the signed URI expires, providing an extra layer of security.
```php
use function Tempest\Router\temporary_signed_uri;
@@ -290,7 +287,7 @@ temporary_signed_uri(
);
```
-To ensure the validity of a signed URL, you should call the `hasValidSignature` method on the {`Tempest\Router\UriGenerator`} class.
+To ensure the validity of a signed URL, call the `hasValidSignature` method on the {b`Tempest\Router\UriGenerator`} class.
```php
final class PasswordlessAuthenticationController
@@ -302,23 +299,21 @@ final class PasswordlessAuthenticationController
public function __invoke(Request $request): Response
{
if (! $this->uri->hasValidSignature($request)) {
- return new Invalid();
+ throw new HttpRequestFailed(Status::UNPROCESSABLE_CONTENT);
}
- // ...
+ // …
}
}
```
### Matching the current URI
-To determine whether the current request matches a specific controller action, Tempest provides the `is_current_uri` function. This function accepts the same arguments as `uri`, and returns a boolean.
+To determine whether the current request matches a specific controller action, Tempest provides the {b`\Tempest\Router\is_current_uri()`} function. This function accepts the same arguments as `uri`, and returns a boolean.
-```php
+```php "GET /aircraft/1"
use function Tempest\Router\is_current_uri;
-// Current URI is: /aircraft/1
-
// Providing no argument to the right controller action will match
is_current_uri(AircraftController::class); // true
@@ -331,15 +326,19 @@ 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.
+Web applications need to process user input—whether it is form submissions, search queries, API payloads, or filter parameters.
+
+Tempest handles this by injecting {b`Tempest\Http\Request`} objects into controller actions, giving access to the request's body, query parameters, method, and headers 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.
+In most situations, the data expected from a request is structured. Clients are expected to send specific values and follow specific rules.
+
+The idiomatic approach is to use request classes. These are classes with public properties that correspond to the data to retrieve from the request. Tempest automatically validates these properties using PHP's type system, in addition to optional [validation attributes](../2-features/03-validation) when needed.
-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/03-validation) if needed.
+A request class must implement {b`Tempest\Http\Request`} and use the {b`Tempest\Http\IsRequest`} trait, which provides the default implementation.
-A request class must implement {`Tempest\Http\Request`} and should use the {`Tempest\Http\IsRequest`} trait, which provides the default implementation.
+:::code-group
```php app/RegisterAirportRequest.php
use Tempest\Http\Request;
@@ -353,22 +352,21 @@ final class RegisterAirportRequest implements Request
#[HasLength(min: 10, max: 120)]
public string $name;
- public ?DateTimeImmutable $registeredAt = null;
-
+ #[HasLength(min: 2)]
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.
-:::
+ #[HasLength(min: 4, max: 4)]
+ public string $icaoCode;
-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.
+ public ?DateTime $registeredAt = null;
+}
+```
```php app/AirportController.php
use Tempest\Router\Post;
use Tempest\Http\Responses\Redirect;
-use function Tempest\map;
+
+use function Tempest\Mapper\map;
use function Tempest\Router\uri;
final readonly class AirportController
@@ -376,26 +374,45 @@ final readonly class AirportController
#[Post(uri: '/airports/register')]
public function store(RegisterAirportRequest $request): Redirect
{
- $airport = map($request)->to(Airport::class)->save();
+ $airport = map($request)
+ ->to(Airport::class)
+ ->save();
return new Redirect(uri([self::class, 'show'], id: $airport->id));
}
}
```
+```php app/Airport.php
+#[Table('airports')]
+final class Airport
+{
+ public string $name;
+ public string $servedCity;
+ public string $icaoCode;
+ public ?DateTime $registeredAt = null;
+}
+```
+
+:::
+
+Once a request class is created, it can be injected into a controller action. Tempest fills its properties and validates them, providing a properly-typed object.
+
:::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).
:::
### Sensitive fields
-When handling sensitive data such as passwords or tokens, you may not want these values to be stored in the session or re-displayed in forms after validation errors. You can mark request properties as sensitive using the {b`#[Tempest\Http\SensitiveField]`} attribute:
+When a validation error occurs, Tempest filters out sensitive fields from the original values stored in the session. This prevents sensitive data from being re-populated in forms after a redirect.
+
+Request properties can be marked as sensitive using the {b`#[Tempest\Http\SensitiveField]`} attribute:
```php app/ResetPasswordRequest.php
use Tempest\Http\Request;
use Tempest\Http\IsRequest;
use Tempest\Http\SensitiveField;
-use Tempest\Validation\Rules\HasMinLength;
+use Tempest\Validation\Rules\HasLength;
final class ResetPasswordRequest implements Request
{
@@ -404,19 +421,14 @@ final class ResetPasswordRequest implements Request
public string $email;
#[SensitiveField]
- #[HasMinLength(8)]
+ #[HasLength(min: 8)]
public string $password;
-
- #[SensitiveField]
- public string $password_confirmation;
}
```
-When a validation error occurs, Tempest will filter out sensitive fields from the original values stored in the session. This prevents sensitive data from being re-populated in forms after a redirect.
-
### 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.
+For simpler use cases, a value can be retrieved from the body or the query parameter using the {b`Tempest\Http\Request`}'s `get` method. Other methods, such as `hasBody` or `hasQuery`, are also available.
```php app/AircraftController.php
use Tempest\Router\Get;
@@ -435,30 +447,32 @@ final readonly class AircraftController
## 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?
+When users submit forms—like updating profile settings, or posting comments—the data needs validation before processing. Tempest automatically validates request objects using type hints and validation attributes, then provides errors back to users when something is wrong.
-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:
+On validation failure, Tempest either redirects back to the form (for web pages) or returns a 422 response (for stateless requests). Validation errors are available in two places:
- As a JSON encoded string in the `{txt}X-Validation` header
-- Within the session with the `Session::VALIDATION_ERRORS` key
+- Through the `b{Tempest\Http\Session\FormSession}` class
-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. For web pages, you also need a way to show the errors when they occur; Tempest comes with some built-in view components to help you with that.
+For web pages, Tempest also provides built-in view components to display errors when they occur.
```html
-
-
-
-
-
+
+
+
```
-`{html}` is a view component that will automatically include the CSRF token, as well as default to sending `POST` requests. `{html}` is a view component that renders a label, input field, and validation errors all at once. In practice, you'll likely want to make changes to these built-in view components. That's why you can run `./tempest install view-components` and select the components you want to pull into your project. You can [read more about installing view components here](../1-essentials/02-views.md#built-in-components).
+`{html}` is a view component that defaults to sending `POST` requests. `{html}` is a view component that renders a label, input field, and validation errors all at once.
+
+:::info
+These built-in view components can be customized. Run `./tempest install view-components` and select the components to pull into the project. [Read more about installing view components here](../1-essentials/02-views.md#built-in-components).
+:::
## 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.
+Middleware can be applied to handle tasks between receiving a request and sending a response. To specify middleware for a route, add it to the `middleware` argument of a route attribute.
```php app/ReceiveInteractionController.php
use Tempest\Router\Get;
@@ -474,11 +488,11 @@ final readonly class ReceiveInteractionController
}
```
-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.
+The middleware class must be an invokable class that implements the {b`Tempest\Router\HttpMiddleware`} interface. This interface has an `{:hl-property:__invoke:}()` method that accepts the current request as its first parameter and {b`Tempest\Router\HttpMiddlewareCallable`} as its second parameter.
-`HttpMiddlewareCallable` is an invokable class that forwards the `$request` to its next step in the pipeline.
+{b`Tempest\Router\HttpMiddlewareCallable`} is an invokable class that forwards the `$request` to its next step in the pipeline.
-```php
+```php app/ValidateWebhook.php
use Tempest\Router\HttpMiddleware;
use Tempest\Router\HttpMiddlewareCallable;
use Tempest\Http\Request;
@@ -504,7 +518,7 @@ final readonly class ValidateWebhook implements HttpMiddleware
### 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:
+All middleware classes are sorted based on their priority. By default, each middleware has the "normal" priority, which can be overridden using the {b`#[Tempest\Core\Priority]`} attribute:
```php
use Tempest\Core\Priority;
@@ -514,11 +528,11 @@ 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`.
+Priority is defined using an integer. However, for consistency reasons, it is recommended to use of the built-in {b`Tempest\Core\Priority`} constants.
### Middleware discovery
-Global middleware classes are discovered and sorted based on their priority. You can make a middleware class non-global by adding the {b`#[Tempest\Discovery\SkipDiscovery]`} attribute:
+Global middleware classes are discovered and sorted based on their priority. A middleware class can be made non-global by annotating it with the {b`#[Tempest\Discovery\SkipDiscovery]`} attribute:
```php
use Tempest\Discovery\SkipDiscovery;
@@ -528,31 +542,42 @@ final readonly class ValidateWebhook implements HttpMiddleware
{ /* … */ }
```
+### Cross-site request forgery protection
+
+Tempest provides [cross-site request forgery](https://en.wikipedia.org/wiki/Cross-site_request_forgery) protection based on the presence and values of the [`{txt}Sec-Fetch-Site`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site) and [`{txt}Sec-Fetch-Mode`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode) headers through the {b`Tempest\Router\PreventCrossSiteRequestsMiddleware`} middleware, included by default in all requests.
+
+Unlike traditional CSRF tokens, this approach uses browser-generated headers that cannot be forged by external websites:
+
+- [`{txt}Sec-Fetch-Site`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Site) indicates whether the request came from the same domain, subdomain, a different site or if it was user-initiated, such as typing the URL directly,
+- [`{txt}Sec-Fetch-Mode`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Sec-Fetch-Mode) allows distinguishing between requests originating from a user navigating between HTML pages, and requests to load images and other resources.
+
+:::info
+This middleware requires browsers that support `{txt}Sec-Fetch-*` headers, which is the case for all modern browsers. You may [exclude this middleware](#excluding-route-middleware) and implement traditional CSRF protection using tokens if you need to support older browsers.
+:::
+
### Excluding route middleware
-Some routes may not require specific global middleware to be applied. For instance, API routes do not need CSRF protection. You may skip specific middleware by using the `without` argument of the route attribute.
+Some routes do not require specific global middleware to be applied. For instance, a publicly accessible health check endpoint could bypass rate limiting that's applied to other routes. Specific middleware can be skipped by using the `without` argument of the route attribute.
-```php app/Slack/ReceiveInteractionController.php
-use Tempest\Router\Post;
+```php app/HealthCheckController.php
+use Tempest\Router\Get;
use Tempest\Http\Response;
-final readonly class ReceiveInteractionController
+final readonly class HealthCheckController
{
- #[Post('/slack/interaction', without: [VerifyCsrfMiddleware::class, SetCookieMiddleware::class])]
+ #[Get('/health', without: [RateLimitMiddleware::class])]
public function __invoke(): Response
{
- // …
+ return new Ok(['status' => 'healthy']);
}
}
```
-## Route decorators (route groups)
-
-Route decorators are Tempest's way to manage routes in bulk; it's a feature similar to route groups in other frameworks. Route decorators are attributes that implement the {b`\Tempest\Router\RouteDecorator`} interface. A route decorator's task is to make changes or add functionality to whether route it's associated with. Tempest comes with a few built-in route decorators, and you can make your own as well.
+## Route decorators
-In most cases, you'll want to add route decorators to a controller class, so that they are applied to all actions of that class:
+When building an API or an administration panel, routes often share common configuration—like a URL prefix (`/api`), authentication middleware, or stateless behavior. Route decorators are attributes that can be annotated to controller classes or methods to apply common configuration.
-```php
+```php app/Books/ApiController.php
use Tempest\Router\Prefix;
use Tempest\Router\Get;
@@ -567,27 +592,15 @@ final readonly class ApiController
}
```
-However, route decorators may also be applied to individual controller actions:
-
-```php
-use Tempest\Router\Stateless;
-use Tempest\Router\Get;
-
-final readonly class BlogPostController
-{
- #[Stateless]
- #[Get('/rss')]
- public function rss(): Response { /* … */ }
-}
-```
-
### Built-in route decorators
-These route decorators are provided by Tempest:
+Tempest includes several route decorators to handle common scenarios—like providing routes without session overhead, organizing routes under a common prefix, or applying authentication across an entire controller.
+
+These decorators save you from creating custom implementations for frequently-needed patterns.
#### `#[Stateless]`
-When you're building API endpoints, RSS feeds, or any other kind of page that does not require any cookie or session data, you may use the {b`#[Tempest\Router\Stateless]`} attribute, which will remove all state-related logic:
+For API endpoints, RSS feeds, or any other kind of page that does not require cookie or session data, use the {b`#[Tempest\Router\Stateless]`} attribute to remove all state-related logic:
```php
use Tempest\Router\Stateless;
@@ -603,7 +616,7 @@ final readonly class BlogPostController
#### `#[Prefix]`
-Adds a prefix to all associated routes.
+Adds a prefix to the URI for all associated routes.
```php
use Tempest\Router\Prefix;
@@ -628,7 +641,7 @@ Adds middleware to all associated routes.
use Tempest\Router\WithMiddleware;
use Tempest\Router\Get;
-#[Middleware(AuthMiddleware::class, AdminMiddleware::class)]
+#[WithMiddleware(AuthMiddleware::class, AdminMiddleware::class)]
final readonly class AdminController { /* … */ }
```
@@ -639,14 +652,15 @@ Explicitly removes middleware to all associated routes.
```php
use Tempest\Router\WithoutMiddleware;
use Tempest\Router\Get;
+use Tempest\Router\PreventCrossSiteRequestsMiddleware;
-#[WithoutMiddleware(VerifyCsrfMiddleware::class, SetCookieMiddleware::class)]
+#[WithoutMiddleware(PreventCrossSiteRequestsMiddleware::class)]
final readonly class StatelessController { /* … */ }
```
### Custom route decorators
-Building your own route decorators is done by implementing the {b`\Tempest\Router\RouteDecorator`} interface and marking your decorator as an attribute.
+Custom route decorators are built by implementing the {b`\Tempest\Router\RouteDecorator`} interface and marking the decorator as an attribute. The `decorate()` method receives the current {b`Tempest\Router\Route`} as a parameter, and must return the modified route.
```php
use Attribute;
@@ -657,7 +671,7 @@ final readonly class Auth implements RouteDecorator
{
public function decorate(Route $route): Route
{
- $route->middleare[] = AuthMiddleware::class;
+ $route->middleware[] = AuthMiddleware::class;
return $route;
}
@@ -666,16 +680,18 @@ final readonly class Auth implements RouteDecorator
## 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.
+All requests to a controller action expect a response to be returned to the client. This is done by returning a {b`Tempest\View\View`} or a {b`Tempest\Http\Response`} object.
+
+For simpler use cases or debugging purposes, scalar values and arrays can also be returned directly. Tempest automatically converts these values into proper responses.
### 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.
+Returning a view is a shorthand for returning a successful response with that view. The {b`Tempest\view()`} function can be used directly to construct a view.
```php app/Aircraft/AircraftController.php
use Tempest\Router\Get;
use Tempest\View\View;
-use function Tempest\view;
+use function Tempest\View\view;
final readonly class AircraftController
{
@@ -690,23 +706,22 @@ final readonly class AircraftController
}
```
-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).
+Tempest has a templating system inspired by modern front-end frameworks like [Vue](https://vuejs.org). Read more about views in the [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.
+Tempest provides several response classes for common use cases, all implementing the {b`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.
+- {b`Tempest\Http\Responses\Ok`} — the 200 response. Accepts an optional body.
+- {b`Tempest\Http\Responses\Created`} — the 201 response. Accepts an optional body.
+- {b`Tempest\Http\Responses\Redirect`} — redirects to the specified URI.
+- {b`Tempest\Http\Responses\Back`} — redirects to previous page, accepts a fallback.
+- {b`Tempest\Http\Responses\Download`} — downloads a file from the browser.
+- {b`Tempest\Http\Responses\File`} — shows a file in the browser.
+- {b`Tempest\Http\Responses\NotFound`} — the 404 response. Accepts an optional body.
+- {b`Tempest\Http\Responses\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:
+The following example conditionally returns a {b`Tempest\Http\Responses\Redirect`}, otherwise letting the user download a file by sending a {b`Tempest\Http\Responses\Download`} response:
```php app/FlightPlanController.php
use Tempest\Router\Get;
@@ -719,9 +734,7 @@ final readonly class FlightPlanController
#[Get('/{flight}/flight-plan/download')]
public function download(Flight $flight): Response
{
- $allowed = /* … */;
-
- if (! $allowed) {
+ if (! $this->accessControl->isGranted('view', $flight)) {
return new Redirect('/');
}
@@ -732,9 +745,7 @@ final readonly class FlightPlanController
### 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.
+When the response's status code needs to be dynamically computed without using a condition to send the corresponding response object, return an instance of {b`Tempest\Http\GenericResponse`} and specify the status code and an optional body.
```php app/CreateFlightController.php
use Tempest\Router\Get;
@@ -761,9 +772,9 @@ final readonly class CreateFlightController
### 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.
+There are situations where the same kind of response is sent in multiple places, or where a proper API is needed 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:
+Custom response classes can be created by implementing {b`Tempest\Http\Response`}, which default implementation is provided by the {b`Tempest\Http\IsResponse`} trait:
```php app/AircraftRegistered.php
use Tempest\Http\IsResponse;
@@ -787,13 +798,13 @@ final class AircraftRegistered implements Response
### Specifying content types
-Tempest is able to automatically infer the response's content type, usually inferred from the request's `Accept` header.
+Tempest automatically infers the response's content type, typically from the request's `{txt}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`}.
+However, the content type can be overridden manually by using the `setContentType` method on {b`Tempest\Http\Response`} classes. This method accepts a case of {b`Tempest\Http\ContentType`}.
```php app/JsonController.php
use Tempest\Router\Get;
-use Tempest\Router\ContentType;
+use Tempest\Http\ContentType;
use Tempest\Http\Response;
use Tempest\Http\Responses\Ok;
@@ -811,14 +822,14 @@ final readonly class JsonController
### 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.
+There are situations where actions need to be taken on a response right before it is sent to the client. For instance, custom error pages can be displayed when an exception occurred, or a redirect can be performed 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:
+This can be done using a response processor. Similar to [view processors](./02-views.md#pre-processing-views), these are classes that implement the {b`Tempest\Router\ResponseProcessor`} interface. In the `process()` method, the response object can be mutated and returned:
```php app/ErrorResponseProcessor.php
-use function Tempest\view;
+use function Tempest\View\view;
-final class ErrorResponseProcessor implements ResponseProcessor
+final readonly class ErrorResponseProcessor implements ResponseProcessor
{
public function process(Response $response): Response
{
@@ -831,41 +842,9 @@ final class ErrorResponseProcessor implements ResponseProcessor
}
```
-## 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.
-
## Session management
-Sessions in Tempest are managed by the {b`Tempest\Http\Session\Session`} class. You can inject it anywhere you need it. As soon as the `Session` is injected, it will be started behind the scenes.
+Sessions in Tempest are managed by the {b`Tempest\Http\Session\Session`} class. It can be injected anywhere needed. As soon as the {b`Tempest\Http\Session\Session`} is injected, it is started behind the scenes.
```php
use Tempest\Http\Session\Session;
@@ -876,7 +855,7 @@ final readonly class TodoController
private Session $session,
) {}
- #[Post('/select/{todo}']
+ #[Post('/select/{todo}')]
public function select(Todo $todo): View
{
if ($this->session->get('selected_todo') === $todo->id) {
@@ -892,12 +871,14 @@ final readonly class TodoController
### Flashing values
-When you need to "flash" something to the user — in other words: show something once and clear if after refresh — you can use the `flash()` method on the session:
+After saving data or performing an action, it is often needed to show users a success message, error notification, or status update that appears once and then disappears after they refresh the page.
+
+Use the `flash()` method on the {b`Tempest\Http\Session\Session`} to store a value that lasts for the next request only:
```php
public function store(Todo $todo): Redirect
{
- $this->session->flash('message', 'Save was successful');
+ $this->session->flash('message', value: 'Save was successful');
return new Redirect('/');
}
@@ -909,9 +890,9 @@ Tempest supports file and database-based sessions, the former being the default
#### File sessions
-When using file-based sessions, which is the default, session data will be stored in files within the specified directory, relative to `.tempest`. You may configure the path and expiration duration like so:
+When using file-based sessions, which is the default, session data is stored in files within the specified directory, relative to `.tempest`. The path and expiration duration can be configured as follows:
-```php app/Config/session.config.php
+```php app/session.config.php
use Tempest\Http\Session\Config\FileSessionConfig;
use Tempest\DateTime\Duration;
@@ -923,15 +904,15 @@ return new FileSessionConfig(
#### Database sessions
-Tempest provides a database-based session driver, particularly useful for applications that run on multiple servers, as the session data can be shared across all instances.
+Tempest provides a database-based session driver, particularly useful for applications that run on multiple servers, as session data can be shared across all instances.
-Before using database sessions, a dedicated table is needed. Tempest provides a migration, which may be installed in your project using its installer:
+Before using database sessions, a dedicated table is needed. Tempest provides a migration that can be installed using its installer:
```sh
./tempest install sessions:database
```
-This installer will also suggest creating the configuration file that sets up database sessions, with a default expiration of 30 days:
+This installer also suggests creating the configuration file that sets up database sessions, with a default expiration of 30 days:
```php app/Sessions/session.config.php
use Tempest\Http\Session\Config\DatabaseSessionConfig;
@@ -944,15 +925,15 @@ return new DatabaseSessionConfig(
### Session cleaning
-Sessions expire based on the last activity time. This means that as long as a user is actively using your application, their session will remain valid.
+Sessions expire based on the last activity time. This means that as long as a user is actively using the application, their session remains valid.
-Outdated sessions must occasionally be cleaned up. Tempest comes with a built-in command to do so, `session:clean`. This command makes use of the [scheduler](../2-features/11-scheduling.md). If you have scheduling enabled, it will automatically run behind the scenes.
+Outdated sessions must occasionally be cleaned up. Tempest provides a built-in command to do so, `session:clean`. This command uses the [scheduler](../2-features/11-scheduling.md): with scheduling enabled, it automatically runs behind the scenes.
## 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.
+During requests, tasks that take a few seconds to complete are sometimes needed. 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:
+Tempest provides a way to perform that task after the response has been sent, so the client does not 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;
@@ -974,7 +955,7 @@ final readonly class TrackVisitMiddleware implements HttpMiddleware
}
```
-The `defer` callback may accept any parameter that the container can inject.
+The `defer` callback can 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.
@@ -982,9 +963,9 @@ Task deferring only works if [`fastcgi_finish_request()`](https://www.php.net/ma
## 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).
+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. 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.
+The router testing utility provides methods for all HTTP verbs. These methods 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
diff --git a/docs/1-essentials/02-views.md b/docs/1-essentials/02-views.md
index 442f2ec430..ca89e9f51c 100644
--- a/docs/1-essentials/02-views.md
+++ b/docs/1-essentials/02-views.md
@@ -8,7 +8,7 @@ keywords: "Experimental"
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.
-If you'd like to Tempest View as a standalone component in your project, you can read the documentation on how to do so [here](../5-extra-topics/02-standalone-components.md#tempest-view).
+If you'd like to Tempest View as a standalone component in your project, you can read the documentation on how to do so [here](../5-extra-topics/02-standalone-components.md#tempest-view).
### Syntax overview
@@ -45,7 +45,7 @@ As specified in the documentation about [sending responses](./01-routing.md#view
```php app/AircraftController.php
use Tempest\Router\Get;
use Tempest\View\View;
-use function Tempest\view;
+use function Tempest\View\view;
final readonly class AircraftController
{
@@ -73,7 +73,7 @@ return view('views/home.view.php');
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.
+Using view objects will improve static insights in your controllers and view files, and may offer more flexibility regarding how the data may be constructed before being passed on to a view file.
```php
final class AircraftController
@@ -92,6 +92,8 @@ To create a view object, implement the {`Tempest\View\View`} interface, and add
use Tempest\View\View;
use Tempest\View\IsView;
+use function Tempest\root_path;
+
final class AircraftView implements View
{
use IsView;
@@ -375,7 +377,7 @@ When a single slot is not enough, names can be attached to them. When using a co