Try Sevalla today and get $50 free credits

Blog

Tempted by Tempest — An educational look into the Tempest PHP framework

Discover how Tempest, a modern PHP framework, uses code discovery and the latest PHP features to simplify app development and boost productivity.

·by Steve McDougall

If you've been following PHP's evolution over the years, you've probably noticed the language becoming increasingly expressive. Property hooks, attributes, readonly classes - these aren't just syntactic sugar. They're reshaping how we think about building applications.

Enter Tempest, a PHP framework that takes these modern features seriously and builds something genuinely different around them.

I've spent the last few weeks exploring Tempest, and what struck me isn't just its technical capabilities, but its philosophy. Where most frameworks impose structure through configuration and convention, Tempest discovers structure through intelligent code scanning.

Let me walk you through what this means and why it matters.

The discovery approach: Convention through code

Traditional PHP frameworks require explicit registration of almost everything. Routes go in route files, commands get registered in console kernels, and services need providers. Tempest flips this model entirely with what it calls "discovery."

Here's a simple example that illustrates the difference:

<?php

namespace App\Controllers;

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

final readonly class BookController
{
  #[Get('/books/{id}')]
  public function show(string $id): Response
  {
    $book = Book::find($id);
    return new Response($book->toJson());
  }

  #[Get('/books')]
  public function index(): Response
  {
    $books = Book::all();
    return new Response($books->toJson());
  }
}

That's it. No route files, no explicit registration. Tempest scans your codebase during boot, finds classes with routing attributes, and automatically wires everything together. The same pattern applies to console commands, event listeners, and view components.

This might seem like magic, but it's actually quite straightforward. Tempest uses PHP's reflection capabilities to scan your codebase for specific attributes, then builds its internal routing table, command registry, and service container based on what it finds.

Intelligent data mapping

One area where Tempest really shines is data transformation. If you've worked with APIs or complex data imports, you know how tedious mapping between different formats can be. Tempest's mapper component addresses this with an elegant functional approach:

use function Tempest\map;

// Basic object mapping
$user = map($userData)->to(User::class);

// Collection mapping
$users = map($usersArray)->collection()->to(User::class);

// Custom transformation
$product = map($apiData)->to(Product::class, function($data) {
  return new Product(
    name: $data['product_name'],
    price: Money::fromCents($data['price_cents']),
    category: Category::fromId($data['category_id'])
  );
});

But here's where it gets interesting. Real-world data rarely matches your internal structures perfectly. External APIs might use first_name and last_name while your domain model expects a single name field. Tempest handles this through mapping attributes:

final readonly class Customer
{
  public function __construct(
    #[MapFrom('customer_name')]
    public string $name,

    #[MapFrom('email_address')]
    public string $email,

    #[MapFrom('registration_date')]
    public DateTime $registeredAt
  ) {}
}

When you map data to this class, Tempest automatically handles the field name translations. I've found this particularly useful when integrating with third-party services where you have no control over the data structure they provide.

The mapper also supports strict mode, which ensures that every property gets initialized. You can also build custom mappers for complex scenarios. Everything integrates seamlessly with the framework's discovery system.

Architectural flexibility

Here's something that differentiates Tempest from more opinionated frameworks: it doesn't force architectural decisions on you. Want to use traditional MVC? Great. Prefer Domain-Driven Design with hexagonal architecture? Also great. The framework adapts to your choices rather than imposing its own.

Let me show you what this looks like with a simple order processing example:

// Domain layer - pure business logic
namespace App\Domain;

final readonly class Order
{
  public function __construct(
    private OrderId $id,
    private CustomerId $customerId,
    private array $items
  ) {}

  public function calculateTotal(): Money
  {
    return array_reduce(
      $this->items,
      fn(Money $total, OrderItem $item) => $total->add($item->getSubtotal()),
      Money::zero()
    );
  }
}

// Application layer - use cases
namespace App\Application;

final readonly class OrderService
{
  public function __construct(
    private OrderRepository $orders,
    private PaymentService $payments
  ) {}

  public function processOrder(ProcessOrderCommand $command): Order
  {
    $order = Order::create($command->customerId, $command->items);

    $this->payments->charge($command->paymentMethod, $order->calculateTotal());
    $this->orders->save($order);

    return $order;
  }
}

// Infrastructure layer - framework concerns
namespace App\Http;

final readonly class OrderController
{
  public function __construct(private OrderService $orderService) {}

  #[Post('/orders')]
  public function create(CreateOrderRequest $request): Response
  {
    $order = $this->orderService->processOrder($request->toCommand());
    return new Response($order->toArray());
  }
}

Notice how the framework concerns stay in the infrastructure layer while your business logic remains pure. Tempest discovers and wires everything together without requiring you to compromise your architectural vision.

Command bus for complex workflows

For applications with intricate business logic, Tempest includes a command bus that promotes clean separation of concerns. Commands are simple, immutable data structures representing intentions:

final readonly class SendWelcomeEmail
{
  public function __construct(
    public string $userId,
    public string $emailTemplate
  ) {}
}

final readonly class UpdateUserPreferences
{
  public function __construct(
    public string $userId,
    public array $preferences
  ) {}
}

Handlers process these commands and are automatically discovered through attributes:

final readonly class EmailHandler
{
  public function __construct(
    private MailService $mailer,
    private UserRepository $users
  ) {}

  #[CommandHandler]
  public function sendWelcome(SendWelcomeEmail $command): void
  {
    $user = $this->users->find($command->userId);
    $this->mailer->send($command->emailTemplate, $user->email);
  }
}

Dispatching commands is straightforward:

$this->commandBus->dispatch(new SendWelcomeEmail($user->id, 'welcome'));

The framework includes experimental async support for background processing, which is particularly useful for time-consuming operations like image processing or email sending.

Console commands as first-class citizens

One thing I appreciate about Tempest is how it treats console commands. Instead of feeling like an afterthought bolted onto web controllers, console commands are first-class citizens with their own clean syntax:

final readonly class ImportUsers
{
  public function __construct(
    private UserService $userService,
    private FileProcessor $fileProcessor
  ) {}

  #[ConsoleCommand('import:users {filename} {--format=csv}')]
  public function __invoke(string $filename, string $format = 'csv'): void
  {
    $data = $this->fileProcessor->parse($filename, $format);

    foreach ($data as $userData) {
        $this->userService->createUser($userData);
    }

    echo "Imported " . count($data) . " users\n";
  }
}

The command definition includes argument and option parsing built-in, and like everything else in Tempest, it's discovered automatically.

Database handling: A fresh take on data persistence

Now, let's talk about one of the most critical aspects of any framework: how it handles database interactions. Having worked with Active Record patterns, Data Mappers, and various ORMs over the years, I was curious to see how Tempest approached this fundamental challenge.

Tempest takes what I'd call a "decoupled model" approach. Unlike frameworks that tie your domain objects directly to database concerns, Tempest encourages you to keep your models clean and focused on business logic:

final readonly class User
{
  public function __construct(
    public UserId $id,
    public string $name,
    public EmailAddress $email,
    public DateTime $createdAt
  ) {}

  public function isActive(): bool
  {
    return $this->email->isVerified() &&
            $this->createdAt->diffInDays(now()) > 0;
  }

  public function getDisplayName(): string
  {
    return $this->name ?: $this->email->getLocalPart();
  }
}

Notice what's missing here? No database-specific methods, no magic properties, no inheritance from base model classes. This is a pure domain object that focuses entirely on business logic.

So, how do you actually persist and retrieve these objects? That's where repositories come in:

interface UserRepository
{
  public function find(UserId $id): ?User;
  public function findByEmail(EmailAddress $email): ?User;
  public function save(User $user): void;
  public function delete(UserId $id): void;
}

final readonly class DatabaseUserRepository implements UserRepository
{
  public function __construct(private Database $db) {}

  public function find(UserId $id): ?User
  {
    $userData = $this->db
        ->table('users')
        ->where('id', $id->toString())
        ->first();

    return $userData ? map($userData)->to(User::class) : null;
  }

  public function save(User $user): void
  {
    $data = map($user)->to('array');

    $this->db->table('users')->upsert($data, ['id']);
  }
}

This separation means your business logic stays clean while your persistence logic remains flexible. Want to switch from MySQL to PostgreSQL? Change the repository implementation. Need to add caching? Wrap your repository in a caching decorator. Your domain models remain untouched.

The database query builder itself feels familiar if you've used Laravel's Eloquent, but with some nice touches:

// Simple queries
$users = $this->db->table('users')
  ->where('active', true)
  ->orderBy('created_at', 'desc')
  ->limit(10)
  ->get();

// More complex queries with joins
$orders = $this->db->table('orders')
  ->join('users', 'orders.user_id', '=', 'users.id')
  ->join('products', 'orders.product_id', '=', 'products.id')
  ->where('orders.status', 'completed')
  ->select('orders.*', 'users.name as user_name', 'products.title as product_title')
  ->get();

For migrations, Tempest uses a discovery-based approach (surprise!). Migration classes get automatically discovered by implementing the DatabaseMigration interface.

final readonly 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)
      ->integer('author_id', unsigned: true)
      ->belongsTo('books.author_id', 'authors.id');
  }

  public function down(): QueryStatement|null
  {
    return new DropTableStatement('books');
  }
}

What I find refreshing about this approach is how it encourages good architectural practices without being preachy about it. The framework makes it easy to separate concerns, but doesn't force you into rigid patterns if they don't fit your use case.

Modern PHP features throughout

What sets Tempest apart is how thoroughly it embraces modern PHP. This isn't just about using the latest syntax - it's about designing APIs that feel natural with contemporary PHP patterns.

Take view components, for example:

final readonly class UserProfile implements ViewComponent
{
	public function __construct(
		private ViteConfig $viteConfig,
	) {}

	public static function getName(): string
	{
		return 'x-user-profile';
	}

    public function compile(ViewComponentElement $element): string
    {
	    $entrypoints = match(true) {
		    $element->hasAttributes('entrypoints') => 'entrypoints',
		    default => var_export($this->viteConfig->entrypoints, return: true),
	    };
	return <<<HTML
		<?= \Tempest\\vite_tags({$entrypoints}) ?>
	HTML;
    }
}

The framework includes built-in upgraders that help your codebase evolve alongside PHP itself. As new language features become available, Tempest provides tooling to help you adopt them systematically.

Extensibility without configuration overhead

Despite its opinionated approach to discovery, Tempest remains highly extensible. Need a custom view renderer? Install and tweak the configuration.

// view.config.php
return new ViewConfig(
	rendererClass: \Tempest\View\Renderers\TwigViewRenderer::class,
);

Updating the configuration will load the twig renderer as the default renderer, and the same can be done with Blade or others. The documentation is pretty thorough on this.

Performance and developer experience

In practical terms, Tempest performs well. The discovery system adds minimal overhead since everything gets cached after the initial scan. Boot times in production are comparable to other modern frameworks, typically in the 15-25ms range for moderately complex applications.

More importantly, the development experience feels streamlined. When I need to add a new feature, I focus on the business logic first, then add the minimal framework integration required. I don't have to stop to update configuration files or remember registration steps.

When Tempest makes sense

Tempest works particularly well for:

  • API-heavy applications with complex data transformations
  • Projects where development speed is crucial
  • Teams wanting to leverage modern PHP features
  • Applications requiring architectural flexibility

It might be less suitable for large teams preferring highly opinionated frameworks or projects requiring extensive integration with existing Laravel/Symfony ecosystems.

Getting started

The framework is still evolving, which means you're joining a community actively shaping its development. The Discord community provides excellent support, and the documentation covers most practical scenarios well.

If you're curious about modern PHP development or feeling constrained by existing framework choices, Tempest offers a compelling alternative. It represents a thoughtful approach to framework design that respects both the language's evolution and your intelligence as a developer.

The key insight behind Tempest is simple: modern PHP is expressive enough that frameworks can discover structure rather than impose it. You'll only know whether this resonates with your development style by trying it yourself.

Deep dive into the cloud!

Stake your claim on the Interwebz today with Sevalla's platform!
Deploy your application, database, or static site in minutes.

Get started