Every PHP team I know has the same short list of problems. Somebody needs to know what a record looked like last month. A lock is never released when a job throws. An email goes out for an order that was rolled back a second later. A service gets changed in the middle of a request and nobody can say where. A search box finds nothing because the customer typed "backpack" and the product says "daypack".
You can solve each of these. The question is how much code, how many packages and how many late nights it costs you.
Doppar is a PHP framework built around a simple idea: those problems are common enough that the framework should handle them for you. This post walks through 20 features that show what that looks like. For each one I'll tell you what it is, why you'd use it, and show a short example, the same way you'd explain defer() to a colleague.
A quick word on ordering. The first group is made of features that are unusual. As far as I know, you won't find them built into the core of Laravel or Symfony. Some of the ideas exist elsewhere as packages or in a different shape, so I'll be fair about that as we go. The second group is everyday stuff that Doppar does well and makes pleasant.
Doppar 4.x needs PHP 8.5. Where a feature arrived in a specific release, I say so.
Part 1: The features that make Doppar different
1. Temporal ORM: ask what a record looked like last month
What it is. Add one attribute to a model, and Doppar keeps a full history of every create, update and delete. You can then ask what a record looked like at any point in time, compare two moments, or roll a record back.
Why you'd use it. Audit trails, "who changed this price?", undoing a bad edit, legal records. Normally this means a second table, a pile of observers and a lot of care. Here it's one line.
use Phaseolies\Database\Temporal\Attributes\Temporal;
#[Temporal]
class Contract extends Model
{
protected $table = 'contracts';
protected $creatable = ['title', 'status', 'amount'];
}
Run php pool migrate:temporal once to create the history table. After that:
$then = Contract::at('2024-01-01')->find(42); // the contract as it was
echo $then->status; // 'draft'
$diff = $contract->diff('2024-01-01', '2024-06-01');
// ['changes' => ['status' => ['from' => 'draft', 'to' => 'active']], ...]
$contract->restoreTo('2024-01-01'); // roll it back, and the restore is recorded too
Every change goes through the model's normal save(), so the restore is itself recorded as a new entry in the history.
2. Frozen services: configuration that cannot be changed behind your back
What it is. Mark a service with #[Immutable]. You configure it while the app boots, and from the first time the container hands it out, any write to it throws an exception.
Why you'd use it. Singletons that hold settings (a payment gateway, a tax rate) are shared by the whole process. One careless $payment->taxRate = 0 in a controller and every user is affected. A frozen service turns that quiet bug into a loud one, at the exact line that caused it.
#[Immutable]
class PaymentService
{
use EnforcesImmutability;
public string $gateway = 'stripe';
public float $taxRate = 0.08;
}
// In a launcher, while the app boots
$payment = new PaymentService();
$payment->gateway = config('payment.gateway');
$this->app->singleton(PaymentService::class, fn () => $payment);
Later, anywhere in your app:
$payment->charge(100); // fine
$payment->taxRate = 0.0; // throws ImmutableViolationException
Reading, method calls and isset() always work. Writes, unset() and clone throw. Freezing closure and instance() services arrived in Doppar 4.7.0.
3. Reactive models: react to one property, not the whole save
What it is. #[Watches] lets you attach a small class to a single property. When that property changes and the save succeeds, Doppar calls your class with the old value, the new value and the model.
Why you'd use it. "Do something when the order status changes" is everywhere. Without this you write a model event, check which fields are dirty, compare values, and repeat it for every property.
class Order extends Model
{
protected $creatable = ['user_id', 'status', 'total'];
#[Watches(OrderStatusChanged::class)]
protected $status;
}
class OrderStatusChanged
{
public function handle(mixed $old, mixed $new, Model $model): void
{
if ($new === 'shipped') {
// tell the customer
}
}
}
You can stack several watchers on one property, watch several properties, and make a watch conditional.
4. Semantic search with one attribute, and no API key
What it is. The doppar/embeds package adds #[Embeds]. Mark a column, and Doppar keeps a numeric representation of its meaning up to date every time you save. You then search by meaning instead of by words.
Why you'd use it. LIKE '%backpack%' will never find "rugged daypack for hiking in the rain". Meaning-based search will. The model runs on your own server, so there is no external API, no key and no per-request cost.
class Product extends Model
{
use Embeddable;
protected $creatable = ['name', 'description'];
#[Embeds]
protected $description;
}
$results = Product::whereSimilarTo('description', 'a durable waterproof backpack', limit: 10);
You get a collection of products ordered from most to least similar. You never call an embedding function yourself. Setting the property and saving is enough. There are three storage drivers: brute_force, pgvector and redis. Note that it needs PHP's FFI extension turned on for persistent servers like PHP-FPM, and the docs explain how.
5. #[Transaction]: wrap a controller action in a transaction with one line
What it is. An attribute that runs your controller method inside a database transaction, with optional retries.
Why you'd use it. Most "create an order and its items" actions need a transaction. Writing DB::transaction(...) around the whole body is easy to forget and makes the code nest deeper.
use Phaseolies\Database\Attributes\Transaction;
#[Transaction(attempts: 3)]
#[Route(uri: 'orders', methods: ['POST'])]
public function store(Request $request)
{
$order = Order::create($request->all());
// ...
}
If the method throws, the transaction rolls back. With attempts: 3, a transient failure such as a deadlock is retried up to three times. You can also pick a connection: #[Transaction(connection: 'pgsql')].
6. Templates where you choose the syntax
What it is. Odo is Doppar's template engine. It compiles to plain cached PHP, and every delimiter (directives, echo tags, comments) can be changed in one config file.
Why you'd use it. If a project already uses a syntax that clashes with {{ }} (some JavaScript frameworks do), or you simply prefer another style, you change a config value and carry on. Output is escaped by default.
#if ($user)
<h1>[[ $user->name ]]</h1>
#endif
#foreach ($posts as $post)
<p>[[ $post->title ]]</p>
#endforeach
Don't like # for directives? Set 'directive_prefix' => '@' in runtime/config/odo.php and write @if and @foreach instead.
7. A Bloom filter you can call from your code
What it is. The doppar/bloom package gives you a Bloom filter backed by Redis. It answers "is this probably in the set?" using a small fixed amount of memory.
Why you'd use it. "Have I seen this email or URL before?" without a database query. According to the docs, memory stays around 12.5 MB whether you hold 100,000 items or 10 million, and checks take a fraction of a millisecond. The trade-off is that it can say "probably yes" but never a wrong "no".
if (Bloom::key('email')->add($email)) {
// probably seen before
} else {
// definitely new
}
Bloom::key('email')->has('someone@example.com');
8. A queue that survives crashes and kills stuck jobs
What it is. doppar/queue runs your background jobs on the database or on Redis. It has priorities, unique jobs, retry backoff, and a lease system for recovery.
Why you'd use it. The usual queue nightmares: a worker dies and the job is lost or stuck, or a job hangs forever and blocks the worker. Here, a worker holds a lease on a job. If the worker dies, the lease runs out and another worker takes the job, with its attempt count kept. A job that sets a timeout runs in its own process, so a stuck job gets killed instead of hanging the worker.
#[Queueable(timeout: 60, priority: 90)]
class SendPasswordResetJob extends Job
{
public function __construct(public User $user) {}
public function handle(): void
{
// runs in its own process, stopped after 60 seconds
}
}
Give a job a key and a second copy is refused while the first one waits or runs:
public function uniqueId(): ?string
{
return (string) $this->reportId;
}
Because a job can run twice after a crash, write jobs so that running them again is safe. The docs say the same.
Part 2: Features that make everyday work better
9. Model hooks: put model rules next to the model
What it is. Attach small methods to moments in a model's life: before it is created, after it is deleted, and so on. Use the #[Hook] attribute on a method.
Why you'd use it. Slugs, default values, cache clearing and audit records belong with the model, not scattered across controllers where one of them will forget.
class Post extends Model
{
#[Hook('before_created')]
public function generateSlug(): void
{
$this->slug = str()->slug($this->title);
}
#[Hook('before_updated', when: 'isBeingPublished')]
public function stampPublishedAt(): void
{
$this->published_at = now();
}
public function isBeingPublished(): bool
{
return $this->isDirtyAttr('status') && $this->status === 'published';
}
}
There are ten events. If you need to write without hooks (an import, a data fix), $post->withoutHook()->save() skips them for one write. In 4.7.0, withHooks() was added for the opposite case: Post::where('status', 'draft')->withHooks()->update([...]) runs the hooks for every record in a bulk update.
10. Execution context: one identity for every request, command and job
What it is. Every piece of work in Doppar (an HTTP request, a console command, a queue job) runs inside an execution with its own id, type and metadata. Services can be tied to that execution, so they disappear with it.
Why you'd use it. In a long-running worker, a singleton that holds "the current tenant" will leak into the next job. And when something fails, you want one id that ties together every log line from that request. Both are solved here.
Execution::id(); // "9f2c41e07b1d3a58"
Execution::type(); // "request"
Execution::stamp(['tenant' => 'acme']);
Log::error('Payment failed'); // the log entry carries the id, type and tenant
For per-request state, bind the service to the execution scope, and it can never be seen by the next request:
$this->app->bindExecution(TenantState::class, fn () => new TenantState());
This arrived in Doppar 4.6.0.
11. defer(): clean up right where you create the mess
What it is. defer() works like Go's defer. You register a cleanup callback right after you acquire something, and Doppar runs it when the current execution ends, whether it succeeded or threw.
Why you'd use it. try/finally puts the release at the bottom, far from the acquire. Add a second resource and you nest another level.
$lock = cache()->locked('report:' . $id, seconds: 30);
if (! $lock->get()) {
throw new \RuntimeException('Already running.');
}
defer(fn () => $lock->release());
$report = $this->generateReport($id);
Callbacks run in reverse order, one broken callback never stops the others, and the original exception is never replaced. It works in requests, console commands and queue jobs. One honest limit: if the process is killed (a job timeout kill, kill -9, a power cut), no PHP code runs, so give your locks their own expiry too. This arrived in 4.6.0.
12. Signals: events that wait for the commit
What it is. A signal is a small object that says "this happened". If you emit it inside a transaction, Doppar can hold it back until the transaction has really committed, and drop it if the transaction rolls back.
Why you'd use it. Nobody wants a receipt email for a payment that was rolled back.
#[DeliverOnSettle]
class InvoicePaid
{
public function __construct(public readonly int $invoiceId) {}
}
DB::transaction(function () use ($invoice) {
$invoice->markPaid();
Signal::emit(new InvoicePaid($invoice->id)); // held until the commit
});
Nested transactions hand their signals up to the parent, each database connection settles on its own, and it all runs synchronously with no queue involved. Scaffold one with php pool make:signal InvoicePaid --settle.
13. Routing, binding and rate limits as attributes
What it is. Routes, route-model binding and throttling sit on the controller method, in plain sight.
Why you'd use it. You read the method and see everything about it: its URL, its limits and what it loads.
#[Route(uri: 'login', methods: ['POST'], rateLimit: 10, rateLimitDecay: 1)]
public function login(Request $request) { /* ... */ }
#[Route('/profile/{user}', methods: ['GET'], where: ['user' => '[0-9]+'])]
public function show(#[Model] User $user)
{
// /profile/5 loads the user; /profile/abc is a 404 and never queries the database
}
#[Model('email')] binds on another column. There is also a #[Throttle(maxAttempts: 10, decayMinutes: 1)] attribute, and #[Mapper] for prefixes and middleware on a whole controller. If you prefer a routes file, the Route facade works too. Since 4.2.0 Doppar also reads each controller action once and caches the result, so routing does not use Reflection on every request.
14. Validate a request with a plain class
What it is. Describe your input as a small class with attributes, and Doppar validates the request before your controller code runs.
Why you'd use it. Your controller receives clean, typed data, or the request is rejected. There is no validation code in the action itself.
class BookData
{
#[NotBlank]
#[StringType]
#[Length(max: 255)]
public string $title;
#[NotBlank]
#[Integer]
#[Between(min: 1, max: 5)]
public int $rating;
}
#[Route(uri: 'books', methods: ['POST'])]
public function store(#[BindPayload(strict: true, validate: true)] BookData $book)
{
Book::create($book->toArray());
}
15. Soft deletes and casts, without traits
What it is. Two common model features, switched on with attributes.
Why you'd use it. No trait to remember, no global scope to register, and the setup is visible at a glance.
#[SoftDeletes]
class Post extends Model
{
#[ToBoolean]
protected string $is_featured;
#[ToDateTime]
protected string $published_at;
}
With #[SoftDeletes], delete() marks the row as deleted, and every query leaves trashed rows out unless you ask: withTrashed(), onlyTrashed(), restore(), forceDelete(). Casts work on read and write, so you stop scattering (bool) and json_decode() through your code.
16. Relationships with names that say what they do
What it is. Doppar names relationships by what they do: linkOne, linkMany, bindTo for the inverse, and bindToMany for many-to-many. You load them with embed(), and you can choose the columns.
Why you'd use it. Eager loading without the N+1 problem, and without loading columns you don't need.
class User extends Model
{
public function otp()
{
return $this->linkOne(Otp::class, 'user_id', 'id');
}
}
$user = User::find(1)->otp; // lazy loading
User::query()
->embed('otp', fn ($q) => $q->select('id', 'otp', 'user_id'))
->get(); // one extra query, only those columns
You can ask the other way too: User::present('otp') finds users that have one, User::absent('otp') finds those that don't, and embedCount() returns counts without loading the records. For many-to-many, link(), unlink() and relate() manage the pivot table for you.
17. Stream collections: process a huge file without running out of memory
What it is. A lazy collection built on generators. Each step (map, filter, take) runs only when you actually read the data.
Why you'd use it. Reading a 2 GB log file into an array is a good way to crash a server. A stream reads one line at a time.
$errors = StreamCollection::lines(storage_path('logs/app.log'))
->filter(fn ($line) => str_contains($line, 'ERROR'))
->take(10)
->all();
The file is opened when you start reading and closed when the stream ends, or when you stop early, as take(10) does here.
18. Run system commands without opening a security hole
What it is. doppar/orion runs shell commands, in sequence, in parallel pools, or piped into each other. It is built on Symfony Process.
Why you'd use it. Running commands from PHP is a classic place for command injection. Orion never runs your command through a shell, so $HOME, *, ; and | are just text.
$result = Process::ping('git commit -m "fix the bug"')->execute();
return $result->getOutput();
Process::ping('echo $HOME')->execute()->getOutput(); // "$HOME", the variable is not expanded
When an argument has awkward characters, pass an array and each element goes to the program exactly as it is.
19. A profiler built into your development loop
What it is. doppar/insight adds a toolbar to the page you are working on, plus a history of recent requests.
Why you'd use it. To see the SQL queries, cache hits, session data, logs and timing of a request without leaving the browser. It also shows status codes, response times and errors across several requests, which helps you spot the slow endpoint.
composer require doppar/insight
Register its launcher in runtime/config/app.php and you are done. It is meant for development, so don't leave it switched on in production.
20. AI that runs on your own server
What it is. doppar/ai has two parts. Pipeline runs machine-learning tasks locally (sentiment analysis, text generation and more, over 15 tasks in all) using models from Hugging Face. Agent talks to cloud models such as OpenAI or Gemini.
Why you'd use it. You can add features like sentiment analysis without sending your customers' text to anyone, and without a per-call bill. Use the cloud agents when you need a bigger model.
$result = Pipeline::execute(
task: TaskEnum::SENTIMENT_ANALYSIS,
data: 'I absolutely love this product! Best purchase ever!'
);
// [['label' => 'POSITIVE', 'score' => 0.9998]]
$answer = Agent::using(OpenAI::class)
->withKey(env('OPENAI_API_KEY'))
->model('gpt-3.5-turbo')
->prompt('Explain quantum computing in simple terms')
->send();
A few smaller things that add up
-
env.tomlinstead of.env. Your configuration file has real types: booleans and numbers are not strings that you cast. -
php pool. One console for everything, with generators such asmake:job,make:signal,make:watcher,make:hookandmake:presenter. - API presenters. A dedicated class that decides what your JSON looks like, so your models never leak columns by accident.
-
Launchers. In 4.x, service providers became launchers:
register()andlaunch(), insrc/Launchers. -
Speed. The Doppar docs report about 2,100 requests per second on a tiny endpoint (one
User::find(1)) with Nginx, PHP-FPM and PHP 8.5, in one local run. Treat that as one data point from the authors, not a promise for your app. Measure your own.
So, should it be your go-to framework?
Here is my honest take.
Pick Doppar if you like the features above and want them in the core, working together, instead of hunting for six packages. It's a good fit if you read code more than you write it, because attributes put the important facts right on the class or method. It's also a good fit if you run workers and long-lived processes and you're tired of state leaking between jobs.
Think twice if your team depends on a huge third-party ecosystem, or you need a framework with a decade of Stack Overflow answers. Laravel and Symfony are excellent, and they have that. Doppar is younger and its community is smaller. It also needs PHP 8.5, so check your hosting first.
The best way to decide is to try one feature. Take the problem that annoyed you most this month (history, cleanup, commit-safe emails, frozen config, search) and build that one thing in Doppar. If it feels easier than what you have today, you'll know.
The full documentation is at https://doppar.com/versions/4.x/installation, and you can see what's new in 4.x at https://doppar.com/versions/4.x/releases.
Top comments (0)