Doppar 4.0 is out today.
I want to start this post with something I haven't said out loud before. Doppar 3.0 was a good framework, but if you put it next to Laravel, a lot of people could not tell where one ended and the other began. Service providers with register() and boot(). An App\Http\Kernel. resource_path() and database_path(). A .env file. An app/ folder. Familiar names have a real upside, because they make a new framework easy to try. They also mean the framework is wearing somebody else's t-shirt.
With 3.0 we wore the Laravel t-shirt. With 4.0 we took it off.
That is the short version of why this release matters to me. The long version is the rest of this post, and it is long, because 4.0 touches almost everything a Doppar developer sees in a day: the folder layout, the bootstrapping classes, the config file, the mailer, the path helpers, the attribute imports at the top of every controller. If you are running 3.x, please read all of it before you upgrade. If you are new to Doppar, this is a good place to start, because the framework finally looks like itself.
Why I wanted to change it
Here is the uncomfortable part. Under the surface, Doppar was never a Laravel clone. Models that keep their own history with #[Temporal] and let you time-travel a query. Services you can freeze with #[Immutable] so nothing mutates them after the container builds them. #[Watches] on a property to react to exactly the field that changed, with the old and new value. Casting done with attributes on the property itself instead of a string array. A queue worker that forks a child per job, so a job that hangs gets killed and the worker stays alive. Attribute-driven routing from day one.
None of that came from anywhere else. But when you opened a 3.x project, the first thing you saw was the vocabulary of another framework, and the vocabulary sets the expectation. People read ServiceProvider and assumed the rest would behave the same. Sometimes it did. Often it didn't, and that was confusing for them and unfair to the framework.
So for 4.0 the rule was simple: if a name, a folder, or a convention exists only because another framework has it, question it. Some things survived. A lot didn't.
The 4.0 changes, one by one
1. PHP 8.5 is the minimum
Doppar 3.x needed PHP 8.3. Doppar 4.x needs PHP 8.5. There is no compatibility mode.
I know that is a hard line, and I thought about it for a while. But every version of PHP we support is a version we have to write around. PHP 8.5 brings the pipe operator, first-class Uri objects, clone() with property overrides, and the #[\NoDiscard] attribute. Some of those we can already lean on, others we will use as they settle in, and none of them would be available to the framework or to your application if we kept an 8.3 floor. Raising the minimum now means we don't carry a compatibility tax for the next several years.
The practical rule: upgrade your PHP runtime first, before you touch a single line of application code. Everything below assumes 8.5.
2. Providers are now Launchers
This is the biggest architectural change, and the one you will notice first.
Phaseolies\Providers\ServiceProvider is gone. In its place there is Phaseolies\Launchers\ServiceLauncher, and the two lifecycle methods are renamed to say what they do: register() stays, boot() becomes launch(). Your classes move from app/Providers to src/Launchers.
3.x
<?php
namespace App\Providers;
use Phaseolies\Providers\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
//
}
}
4.x
<?php
namespace App\Launchers;
use Phaseolies\Launchers\ServiceLauncher;
class AppLauncher extends ServiceLauncher
{
/**
* Register any application services.
*
* @return void
*/
public function register(): void
{
//
}
/**
* Launch any application services.
*
* @return void
*/
public function launch(): void
{
//
}
}
The generator follows the rename:
# 3.x
php pool make:provider MyServiceProvider
# 4.x
php pool make:launcher MyLauncher
register() means what it always meant. That is where you put container bindings and raw service wiring. launch() is for anything that needs other services to already exist: loading routes, views, migrations, translations, console commands.
Why rename it at all? Because "boot" tells you nothing. A launcher registers its services, then launches. It is a small change in wording, but when you read a class called AppLauncher with a method called launch(), you know what it is for, and you are not translating from another framework's documentation in your head.
Launchers can also be ghostable, which means the framework registers them lazily the first time one of their declared services is actually resolved, instead of on every request. If a launcher provides something only 5% of your requests need, that 5% is the only place you pay for it.
Package authors, this affects you too. Publishable resources are declared inside launchers now, so the flag on vendor:publish changed:
# 3.x
php pool vendor:publish --provider="Vendor\PackageName\PackageServiceProvider"
# 4.x
php pool vendor:publish --launcher="Vendor\PackageName\PackageLauncher"
If your README still says --provider, your users will hit an unrecognized option error on 4.x.
3. The Kernel is now the Gateway, and the Router stopped extending it
In 3.x, Phaseolies\Support\Router extended your application's App\Http\Kernel. Think about what that means. A class inside the framework inherited from a class that lives in your app. The framework depended on your code, in the wrong direction.
In 4.x that is fixed. App\Http\Kernel becomes App\Http\Gateway, it implements a new GatewayInterface, and the Router depends on that interface instead of extending anything.
// 4.x — src/Http/Gateway.php
namespace App\Http;
use Phaseolies\Middleware\Middleware;
use Phaseolies\Http\Contracts\GatewayInterface;
class Gateway extends Middleware implements GatewayInterface
{
public array $middleware = [/* ... */];
public $middlewareGroups = [/* ... */];
public array $routeMiddleware = [/* ... */];
public function getGlobalMiddleware(): array
{
return $this->middleware;
}
public function getMiddlewareGroups(): array
{
return $this->middlewareGroups;
}
public function getRouteMiddleware(): array
{
return $this->routeMiddleware;
}
}
Your three arrays keep exactly the same shape. Only the file name, the class name, and the interface are new. The framework still finds App\Http\Gateway by convention, so in the common case you don't bind anything by hand.
This one is not cosmetic, and I want to be clear about that. Once the router talks to an interface, you can swap the gateway in tests, and packages can build on the router without inheriting from something that belongs to the app. There is one side effect worth knowing about. Something that used to call applyMiddleware() directly on the router now has to go through the gateway. I ran into exactly this while fixing our own profiler package for 4.x, so if you maintain a package that registers global middleware, check for it.
4. A folder structure that belongs to Doppar
The whole skeleton was reorganised. Your application code lives in src/. Views and language files live in templates/. Migrations and seeders live in schema/. Configuration, routes, and the bootstrap file live in runtime/. The bootstrap/ folder is gone, and its work is done by runtime/app.php.
| 3.x | 4.x |
|---|---|
app/ |
src/ |
app/Providers/ |
src/Launchers/ |
config/ |
runtime/config/ |
routes/ |
runtime/routes/ |
bootstrap/ |
runtime/app.php |
database/migrations/ |
schema/migrations/ |
database/seeds/ |
schema/seeders/ |
resources/views/ |
templates/views/ |
lang/ |
templates/lang/ |
storage/, public/, and tests/ stay where they were. The test folder now ships a BootstrapApplication.php next to TestCase.php.
I like this layout for a specific reason. Open a fresh 4.x project and each top-level folder answers one question. What is my code? src/. What does the framework need at runtime to start? runtime/. What describes my database? schema/. What does the user see? templates/. In the old layout config, routes, and bootstrap were three separate folders for what is really one concern.
Two path helpers changed to match, and they have no deprecated alias:
// 3.x
$view = resource_path('views/layouts/app.Odo.php');
$migrations = database_path('migrations/');
// 4.x
$view = template_path('views/layouts/app.Odo.php');
$migrations = schema_path('migrations/');
Search your project for every call. A leftover resource_path() or database_path() is a fatal error on 4.x, and I would rather you find it with a search than find it in production.
5. Attributes now live next to the thing they configure
In 3.x every attribute we ship lived in one flat namespace, Phaseolies\Utilities\Attributes. That is a junk drawer. #[Route] had nothing to do with #[Transaction], but they sat side by side.
In 4.x each attribute moved to the subsystem it belongs to:
| Attribute | 4.x namespace |
|---|---|
#[Bind], #[Resolver]
|
Phaseolies\DI\Attributes |
#[Transaction] |
Phaseolies\Database\Attributes |
#[Model] |
Phaseolies\Database\Entity\Attributes |
#[BindPayload] |
Phaseolies\Http\Requests\Attributes |
#[Middleware] |
Phaseolies\Middleware\Attributes |
#[Route], #[Mapper], #[Throttle]
|
Phaseolies\Support\Router\Attributes |
Every one of these is a namespace move and nothing else. Names, signatures, and behaviour are unchanged. All you do is fix the use line at the top of each controller, model, and middleware:
// 3.x
use Phaseolies\Utilities\Attributes\Route;
use Phaseolies\Utilities\Attributes\Middleware;
// 4.x
use Phaseolies\Support\Router\Attributes\Route;
use Phaseolies\Middleware\Attributes\Middleware;
One attribute did not move, it was removed: #[CastToDate]. It was deprecated in favour of the attribute cast system (#[ToDate] and friends), and in a major version we finish what we announced. If any of your models still use it, delete the import and the line.
6. env.toml replaces .env
This is my favourite small change in the release, because it fixes a bug that every PHP developer has hit at least once.
A .env file only produces strings. APP_DEBUG=false is not the boolean false. It is the five-character string "false", and in PHP (bool) "false" is true. So a debug flag you turned off stays on, and you find out when you see a stack trace on a live site.
4.x reads configuration from env.toml, and TOML has real types:
# env.toml
APP_NAME = "Doppar"
APP_ENV = "local"
APP_DEBUG = false
APP_URL = "http://localhost:8000"
DB_CONNECTION = "mysql"
DB_PORT = 3306
env('APP_DEBUG') returns an actual bool. env('DB_PORT') returns an actual int. You can stop writing (bool) env(...) and hoping.
A few things behave differently from what you're used to:
-
Strings must be quoted.
APP_ENV = "local", notAPP_ENV = local. Booleans and numbers are written bare. -
The file must stay flat. TOML supports
[section]tables, but everyenv()call expects a flat key, so Doppar rejects a nested table with a clear error instead of silently ignoring it. -
No interpolation.
MAIL_FROM_NAME="${APP_NAME}"has no equivalent. Write the literal value. -
Real environment variables still win. If
APP_DEBUGis already set at the process level (Docker, systemd, your host's panel), the value inenv.tomlis skipped for that key, exactly like.envalways worked.
If env.toml is missing entirely, Doppar still boots and falls back to whatever real environment variables exist. That is handy for containers where configuration is injected and no file is shipped.
Remember to update your .gitignore too: .env becomes env.toml, and !.env.example becomes !env.toml.example.
7. Symfony Mailer replaces PHPMailer
Mail now runs on Symfony Mailer. PHPMailer is gone from the framework.
What does not change is what you actually write. Mailables keep their contract: subject(), content(), attachment(). The make:mail stub is the same. If your code only builds mailables and sends them, you may not need to touch it at all.
What does change:
-
runtime/config/mail.phpneeds updating. Theqmailand plainmail(PHP'smail()) mailers are gone, because Symfony Mailer doesn't support them. Move tosmtp,sendmail, or a DSN-based provider bridge. -
Custom drivers need a rewrite. A class implementing
MailDriverInterfaceshould now be a SymfonyTransportInterface. - CC and BCC really deliver now. Under the old behaviour they were not always handed to the SMTP server as real envelope recipients. If anything downstream relied on that, check it.
The nicest part is the DSN. One value replaces a whole block of host, port, username, and password settings:
MAILER_DSN = "smtp://user:pass@smtp.mailgun.org:587"
failover and roundrobin transports are supported too, if you want more than one provider behind a single mailer.
In composer.json, swap phpmailer/phpmailer for symfony/mailer: ^8.1.
8. Validate DTOs with attributes
This one is new, and I think it is going to change how many of you write controllers.
You already had #[BindPayload] to turn a request into a typed DTO. In 4.x you can also say what a valid payload looks like, on the properties themselves:
<?php
namespace App\DTO;
use Phaseolies\Validation\Attributes\Between;
use Phaseolies\Validation\Attributes\Integer;
use Phaseolies\Validation\Attributes\Length;
use Phaseolies\Validation\Attributes\NotBlank;
use Phaseolies\Validation\Attributes\StringType;
final class BookData
{
#[NotBlank]
#[StringType]
#[Length(max: 255)]
public string $title;
#[NotBlank]
#[Integer]
#[Between(min: 1, max: 5)]
public int $rating;
}
Then switch validation on where the DTO is bound:
#[Route(uri: 'books', methods: ['POST'])]
public function store(
#[BindPayload(strict: true, validate: true)]
BookData $book,
) {
Book::create($book->toArray());
return redirect('/books');
}
The payload is validated before the DTO is hydrated and handed to your controller. If the input is bad, your method never runs. The rules live next to the data they protect, so when someone changes the DTO they see the rules in the same file. The built-in constraints are #[NotBlank], #[StringType], #[Integer], #[Length], and #[Between].
9. Semantic search with doppar/embeds
4.0 ships a new package, doppar/embeds, that adds semantic search to your models with an attribute.
use Phaseolies\Database\Entity\Model;
use Doppar\Embeds\Attributes\Embeds;
use Doppar\Embeds\Concerns\Embeddable;
class Product extends Model
{
use Embeddable;
#[Embeds]
protected $description;
}
$results = Product::whereSimilarTo('description', 'a durable waterproof backpack', limit: 10);
Mark a column with #[Embeds] and Doppar keeps a numeric representation of its meaning, a vector, in sync as records change. A search for "a durable waterproof backpack" then finds "rugged daypack for hiking in the rain", even though the two share almost no words. A plain LIKE query will never do that.
Two design decisions I care about. First, embeddings are computed locally through doppar/ai, so there is no external API call, no API key, and no per-request bill. Second, storage and ranking are handled by a swappable driver. The default brute_force driver needs no setup and works on every database Doppar supports. When a table gets large, pgvector and redis push the ranking into a real HNSW index.
All three drivers have been tested end to end against real data, including checking that the ranking results made sense and not only that the code ran. If you use the redis driver, read the package documentation first: it needs Redis Stack with the search module, and the module has a minimum Redis version. Loading it into an older Redis can take the server down. That is written down in the docs so you don't find out the hard way.
10. A security pass across the core
I am putting this near the end on purpose, because it is the change that matters most and gets talked about least.
While preparing 4.0 we went through the framework looking for the kind of problems that only show up when someone hostile reads your code. We found some, and fixed them:
-
Encryption moved to authenticated AES-256-GCM. New data is encrypted with an AEAD cipher, so tampering is detected instead of silently decrypting to garbage. Data encrypted with the old AES-256-CBC format still decrypts, so you don't lose anything. Along the way we fixed a bug in how the
APP_KEYwas decoded. -
Path traversal was closed in
Storageand inSupport\File. -
Upload validation looks at the real file. The
mimesrule now inspects file content, so a script renamed tophoto.jpgand sent with a friendlyContent-Typeheader no longer passes. It fails closed: if PHP has no way to detect the type (thefileinfoextension is missing, for example), the file is rejected instead of trusted. Make surefileinfois enabled on your server. -
SQL identifier injection was closed in the query builder. Column names and sort directions that reach the builder are now validated, and a sort direction that isn't
ASCorDESCthrows. -
Mass assignment on
update()is now protected the same way ascreate(). Updates only write attributes listed in the model's$creatable, and a model that doesn't declare$creatablethrows aRuntimeExceptionwhen you save it. This one can bite during an upgrade, so check your models: if any of them has no$creatable, add it before you go live. - The session ID is regenerated after a successful login, which closes the door on session fixation.
And a group of ordinary bugs that were not security issues but were bad enough to deserve a fix in a major release: Schema::table() was generating CREATE TABLE instead of ALTER TABLE, a dependency-injection resolution-order bug, Model::__get() swallowing a \Throwable, after_created hooks running in the wrong order, PostgreSQL enum validation, and base_path() handling of absolute paths on different platforms.
If you're on 3.x and cannot upgrade yet, take security seriously and follow the support policy below. If you can upgrade, this alone is a good reason to.
11. Smaller things, all deliberate
- A redesigned error page. It shows the message, the request (method, URL, date, status code), and a code frame with the failing line highlighted, along with a light and dark toggle and a copy button.
-
Requestno longer reads superglobals in its constructor. It takes its data as explicit arguments now, andRequest::capture()builds one from the current PHP request. This makes requests far easier to construct in tests. If you create aRequestby hand anywhere, pass the data in. -
No global
BASE_PATHconstant. The base path is injected into the application through its constructor. -
vendor:publishasks which launcher to publish when there is more than one, instead of guessing. - CI runs on PHP 8.5.
What this means for you
If you have a 3.x application, the upgrade touches every layer of it, and I won't pretend otherwise. The changes are mostly mechanical, but they are everywhere. Here is the order I would do it in. Each step assumes the one before is finished.
- Upgrade PHP to 8.5 or later. Nothing else works without it.
-
Move the skeleton.
app/tosrc/,database/toschema/(andseeds/toseeders/),resources/totemplates/,config/toruntime/config/,routes/toruntime/routes/, and foldbootstrap/intoruntime/app.php. -
Convert every provider to a launcher. Move the files, extend
ServiceLauncher, renameboot()tolaunch(), and update thelaunchersarray inruntime/config/app.php. -
Rename the path helpers.
resource_path()totemplate_path(),database_path()toschema_path(). -
Fix the attribute imports, and remove any
#[CastToDate]. -
Rename
KerneltoGatewayand implementGatewayInterface. -
Update
vendor:publishflags in scripts and docs:--providerbecomes--launcher. -
Convert
.envtoenv.toml. Quote strings, write booleans and numbers bare, flatten any${VAR}interpolation, and fix.gitignore. -
Swap the mail transport. Remove
phpmailer/phpmailer, addsymfony/mailer, updatemail.php, and rewrite any custom driver. -
Check your models and your server. Every model you save needs a
$creatablelist, and file uploads validated withmimesneed thefileinfoPHP extension. -
Run your tests. CC and BCC delivery changed, and anything that constructs a
Requestby hand needs to pass its data in.
Do all of this on a dedicated branch, and don't mix it with feature work. The launcher rename and the folder move are mechanical, but they touch nearly every file. A clean diff is much easier to review than a mixed one.
The complete step-by-step guide is in the release notes at doppar.com/versions/4.x/releases, and the installation guide is the place to start a fresh project.
Support and versioning
Doppar ships one major version a year, with minor and patch releases as needed in between. Minor releases don't break anything. Patch releases are bug fixes and security fixes. Constraints like ^4.0 are safe to use.
Each major version gets bug fixes for 18 months and security fixes for 24 months. 3.x keeps receiving bug fixes until June 1, 2027 and security fixes until December 1, 2027, so nobody is being forced to upgrade this week. The exact dates for 4.x are in the release notes table.
If you are starting something new, start on 4.0. If you're on 3.x, you have time, and I would use it to plan the upgrade properly instead of rushing it.
What I hope 4.0 is
I said at the top that we took off the Laravel t-shirt. I should be fair about it: I have nothing against the framework that shaped how a generation of PHP developers work. Its ideas made PHP better for everyone, including us. But a framework that only feels familiar has no reason of its own to exist. It needs its own words for things, its own layout, its own opinions, and a reason for you to choose it.
ServiceLauncher, Gateway, template_path(), schema_path(), runtime/, env.toml. These are small words. Together they say: this is Doppar, it has its own way of doing things, and it is confident enough to say so.
Thank you to everyone who filed an issue, sent a pull request, or asked a hard question in the last year. A lot of what is in 4.0 started as somebody saying "this doesn't make sense." You were right.
One more thing: Ripple is coming next week
I've been saving this for the last page.
Next week we are releasing Ripple, a new first-party package that lets you build interactive interfaces in PHP, without writing a JavaScript application on top of your Doppar app. I'll go through how it works here, because I've been reading the source this week and I'm excited about it.
If you've used a server-driven UI library before, you will recognise the shape. The idea is not new, and I won't pretend it is. What's ours is how it is built, and that it is a native Doppar package rather than a port.
You write a component as a PHP class and a view in Odo. The browser calls actions on it. Ripple runs your PHP, re-renders on the server, and sends back only the parts of the page that changed.
Here is the whole counter:
<?php
namespace App\Ripple;
use Doppar\Ripple\Attributes\Invokable;
use Doppar\Ripple\Component;
class Counter extends Component
{
public int $count = 0;
#[Invokable]
public function increment(): void
{
$this->count++;
}
public function render(): mixed
{
return view('ripple.counter');
}
}
<div>
<p>Count: [[ $count ]]</p>
<button type="button" ripple:call="increment">+</button>
</div>
Public properties are the component's state. Methods the browser is allowed to call are marked with #[Invokable], and only those. You mount it on any page with one line, or make it the entire page with a route:
Route::ripple('/counter', \App\Ripple\Counter::class)->name('counter');
How it works
When you click that button, five things happen:
- The browser sends the action and the component's signed state to
/ripple/update. - Ripple hydrates the component from that state and runs
increment(). - It renders the view again.
- It compares the old HTML with the new HTML, and works out the smallest set of changes.
- It sends those changes back and the browser applies them.
Step 4 is the interesting part. Ripple parses the rendered HTML into a lightweight virtual tree, walks the old tree and the new tree together, and produces a list of operations: replace a node, update some attributes, set some text, insert, remove, reorder. Only that list goes over the wire. Many tools in this space send the new HTML and let the browser figure out what changed. Ripple does the diff on the server, so the browser's job is just to apply a short list of instructions. The JavaScript client is about 12 KB minified, and you never have to build or configure it. Ripple copies it into public/ for you and you add one line to your layout.
Security was a design input, not a patch
State travels through the browser, so it has to be treated as untrusted. Ripple signs every component's state with an HMAC-SHA256 signature, using RIPPLE_SIGNING_KEY or falling back to your APP_KEY. On every request the signature is checked before anything is hydrated, and a request with a missing or wrong signature is rejected. That is the default (strict), and in production Ripple throws an error if no signing key is configured, instead of quietly falling back to a development key.
Two attributes give you finer control. #[Locked] on a property means the client can never change it, which is what you want for an order ID or a user ID that the server assigned. #[Computed] on a property makes it available to the view but keeps it out of the state that goes to the browser entirely.
One honest note: the state is signed, but it is not encrypted. The browser can read it, it just can't change it. So don't keep secrets in public properties. Use #[Computed] for anything the user shouldn't see.
What is in the first release
Once you have the basics, the rest is there already:
-
Directives for the things you do on every page:
ripple:call,ripple:bind(with.liveand.debounce.300modifiers),ripple:loading,ripple:confirm,ripple:poll, andripple:navigatefor soft page navigation. -
Lifecycle hooks:
mount(),updating(),updated(),dehydrate(). -
Validation with Doppar's own rules. Errors come back in the same round trip and appear in
$errorsin your view. -
Events between components with
#[On]anddispatch(), plus browser events, so sibling components on a page can react to each other in the same request. -
Query string sync with
#[QueryString], so filters survive a refresh. -
Pagination with a
HasPaginationtrait. -
Lazy loading with
#[Lazy]. Heavy components show a placeholder first and load when they are needed. - File uploads with temporary storage, previews, and a cleanup command.
-
Full-page components with
Route::ripple(), plus#[Layout]and#[Title]. -
A generator:
php pool make:ripple Counter. - A testing API, which I want to spend a second on.
Ripple::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->assertSee('Count: 1');
Ripple::test() mounts the component and sends real update payloads through the same path the browser uses, including the signed state. You get a fluent assertion API for properties, rendered HTML, validation errors, redirects, and events. Ripple's own package has over a hundred tests, and I want yours to be that easy to write.
Why it needed 4.0
Ripple requires PHP 8.5 and Doppar 4.x, and this is a good example of why the changes in this release were worth making. Ripple has its own router that adds Route::ripple(), and it swaps that router in when the package registers. It can only do that cleanly because in 4.0 the router depends on a gateway interface instead of inheriting from your app's Kernel. It installs as a launcher. Its files live in the new layout. In 3.x this would have been a fragile hack. In 4.x it is a normal package.
That is what I mean when I say 4.0 is a foundation. The launcher, gateway, and layout work in this release is what lets the first-party packages sit on top of the framework properly, and Ripple is the first one that really shows it.
Installation will be this short:
composer require doppar/ripple
Register Doppar\Ripple\RippleLauncher in your launchers array, add Ripple::scripts() to your layout, and run php pool make:ripple Counter. The full documentation ships with the package.
It is a first release, so there will be rough edges, and I'd rather hear about them from you early than late. Next week, try it and tell us what breaks.
Doppar 4.0 is the release where the framework stops borrowing a name for everything and starts using its own. I hope you like it as much as I enjoyed building it.
Try it, break it, and tell me what you think.
- Documentation: doppar.com/versions/4.x/installation
- Release notes and upgrade guide: doppar.com/versions/4.x/releases
- Source: github.com/doppar/framework
Top comments (0)