<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nera John</title>
    <description>The latest articles on DEV Community by Nera John (@nera_john_78a15b53cb2a).</description>
    <link>https://dev.to/nera_john_78a15b53cb2a</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3643939%2Fa5d30c24-4c98-4eff-8e44-ea45a2045650.png</url>
      <title>DEV Community: Nera John</title>
      <link>https://dev.to/nera_john_78a15b53cb2a</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nera_john_78a15b53cb2a"/>
    <language>en</language>
    <item>
      <title>Doppar 4 Has Been Released, and Here Is Why It Is Special</title>
      <dc:creator>Nera John</dc:creator>
      <pubDate>Sun, 20 Sep 2026 17:28:06 +0000</pubDate>
      <link>https://dev.to/nera_john_78a15b53cb2a/doppar-4-has-been-released-and-here-is-why-it-is-special-mb5</link>
      <guid>https://dev.to/nera_john_78a15b53cb2a/doppar-4-has-been-released-and-here-is-why-it-is-special-mb5</guid>
      <description>&lt;h2&gt;
  
  
  Doppar 4.0 is out today.
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;register()&lt;/code&gt; and &lt;code&gt;boot()&lt;/code&gt;. An &lt;code&gt;App\Http\Kernel&lt;/code&gt;. &lt;code&gt;resource_path()&lt;/code&gt; and &lt;code&gt;database_path()&lt;/code&gt;. A &lt;code&gt;.env&lt;/code&gt; file. An &lt;code&gt;app/&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;With 3.0 we wore the Laravel t-shirt. With 4.0 we took it off.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I wanted to change it
&lt;/h2&gt;

&lt;p&gt;Here is the uncomfortable part. Under the surface, Doppar was never a Laravel clone. Models that keep their own history with &lt;code&gt;#[Temporal]&lt;/code&gt; and let you time-travel a query. Services you can freeze with &lt;code&gt;#[Immutable]&lt;/code&gt; so nothing mutates them after the container builds them. &lt;code&gt;#[Watches]&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;ServiceProvider&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4.0 changes, one by one
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. PHP 8.5 is the minimum
&lt;/h3&gt;

&lt;p&gt;Doppar 3.x needed PHP 8.3. Doppar 4.x needs &lt;strong&gt;PHP 8.5&lt;/strong&gt;. There is no compatibility mode.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;Uri&lt;/code&gt; objects, &lt;code&gt;clone()&lt;/code&gt; with property overrides, and the &lt;code&gt;#[\NoDiscard]&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The practical rule: upgrade your PHP runtime first, before you touch a single line of application code. Everything below assumes 8.5.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Providers are now Launchers
&lt;/h3&gt;

&lt;p&gt;This is the biggest architectural change, and the one you will notice first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Phaseolies\Providers\ServiceProvider&lt;/code&gt; is gone. In its place there is &lt;code&gt;Phaseolies\Launchers\ServiceLauncher&lt;/code&gt;, and the two lifecycle methods are renamed to say what they do: &lt;code&gt;register()&lt;/code&gt; stays, &lt;code&gt;boot()&lt;/code&gt; becomes &lt;code&gt;launch()&lt;/code&gt;. Your classes move from &lt;code&gt;app/Providers&lt;/code&gt; to &lt;code&gt;src/Launchers&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3.x&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Providers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Phaseolies\Providers\ServiceProvider&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppServiceProvider&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;ServiceProvider&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;register&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;//&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;boot&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;//&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4.x&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Launchers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Phaseolies\Launchers\ServiceLauncher&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AppLauncher&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;ServiceLauncher&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="cd"&gt;/**
     * Register any application services.
     *
     * @return void
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;register&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;//&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="cd"&gt;/**
     * Launch any application services.
     *
     * @return void
     */&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;//&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generator follows the rename:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 3.x&lt;/span&gt;
php pool make:provider MyServiceProvider

&lt;span class="c"&gt;# 4.x&lt;/span&gt;
php pool make:launcher MyLauncher
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;register()&lt;/code&gt; means what it always meant. That is where you put container bindings and raw service wiring. &lt;code&gt;launch()&lt;/code&gt; is for anything that needs other services to already exist: loading routes, views, migrations, translations, console commands.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;AppLauncher&lt;/code&gt; with a method called &lt;code&gt;launch()&lt;/code&gt;, you know what it is for, and you are not translating from another framework's documentation in your head.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Package authors, this affects you too. Publishable resources are declared inside launchers now, so the flag on &lt;code&gt;vendor:publish&lt;/code&gt; changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 3.x&lt;/span&gt;
php pool vendor:publish &lt;span class="nt"&gt;--provider&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Vendor&lt;/span&gt;&lt;span class="se"&gt;\P&lt;/span&gt;&lt;span class="s2"&gt;ackageName&lt;/span&gt;&lt;span class="se"&gt;\P&lt;/span&gt;&lt;span class="s2"&gt;ackageServiceProvider"&lt;/span&gt;

&lt;span class="c"&gt;# 4.x&lt;/span&gt;
php pool vendor:publish &lt;span class="nt"&gt;--launcher&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Vendor&lt;/span&gt;&lt;span class="se"&gt;\P&lt;/span&gt;&lt;span class="s2"&gt;ackageName&lt;/span&gt;&lt;span class="se"&gt;\P&lt;/span&gt;&lt;span class="s2"&gt;ackageLauncher"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your README still says &lt;code&gt;--provider&lt;/code&gt;, your users will hit an unrecognized option error on 4.x.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The Kernel is now the Gateway, and the Router stopped extending it
&lt;/h3&gt;

&lt;p&gt;In 3.x, &lt;code&gt;Phaseolies\Support\Router&lt;/code&gt; extended your application's &lt;code&gt;App\Http\Kernel&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;In 4.x that is fixed. &lt;code&gt;App\Http\Kernel&lt;/code&gt; becomes &lt;code&gt;App\Http\Gateway&lt;/code&gt;, it implements a new &lt;code&gt;GatewayInterface&lt;/code&gt;, and the &lt;code&gt;Router&lt;/code&gt; depends on that interface instead of extending anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 4.x — src/Http/Gateway.php&lt;/span&gt;
&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Http&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Phaseolies\Middleware\Middleware&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Phaseolies\Http\Contracts\GatewayInterface&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Gateway&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Middleware&lt;/span&gt; &lt;span class="kd"&gt;implements&lt;/span&gt; &lt;span class="nc"&gt;GatewayInterface&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$middleware&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="nv"&gt;$middlewareGroups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt; &lt;span class="nv"&gt;$routeMiddleware&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="cm"&gt;/* ... */&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getGlobalMiddleware&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getMiddlewareGroups&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;middlewareGroups&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;getRouteMiddleware&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;array&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;routeMiddleware&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your three arrays keep exactly the same shape. Only the file name, the class name, and the interface are new. The framework still finds &lt;code&gt;App\Http\Gateway&lt;/code&gt; by convention, so in the common case you don't bind anything by hand.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;applyMiddleware()&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. A folder structure that belongs to Doppar
&lt;/h3&gt;

&lt;p&gt;The whole skeleton was reorganised. Your application code lives in &lt;code&gt;src/&lt;/code&gt;. Views and language files live in &lt;code&gt;templates/&lt;/code&gt;. Migrations and seeders live in &lt;code&gt;schema/&lt;/code&gt;. Configuration, routes, and the bootstrap file live in &lt;code&gt;runtime/&lt;/code&gt;. The &lt;code&gt;bootstrap/&lt;/code&gt; folder is gone, and its work is done by &lt;code&gt;runtime/app.php&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;3.x&lt;/th&gt;
&lt;th&gt;4.x&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;app/Providers/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;src/Launchers/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;config/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;runtime/config/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;routes/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;runtime/routes/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bootstrap/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;runtime/app.php&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;database/migrations/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;schema/migrations/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;database/seeds/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;schema/seeders/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;resources/views/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;templates/views/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lang/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;templates/lang/&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;storage/&lt;/code&gt;, &lt;code&gt;public/&lt;/code&gt;, and &lt;code&gt;tests/&lt;/code&gt; stay where they were. The test folder now ships a &lt;code&gt;BootstrapApplication.php&lt;/code&gt; next to &lt;code&gt;TestCase.php&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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? &lt;code&gt;src/&lt;/code&gt;. What does the framework need at runtime to start? &lt;code&gt;runtime/&lt;/code&gt;. What describes my database? &lt;code&gt;schema/&lt;/code&gt;. What does the user see? &lt;code&gt;templates/&lt;/code&gt;. In the old layout &lt;code&gt;config&lt;/code&gt;, &lt;code&gt;routes&lt;/code&gt;, and &lt;code&gt;bootstrap&lt;/code&gt; were three separate folders for what is really one concern.&lt;/p&gt;

&lt;p&gt;Two path helpers changed to match, and they have &lt;strong&gt;no deprecated alias&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 3.x&lt;/span&gt;
&lt;span class="nv"&gt;$view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resource_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'views/layouts/app.Odo.php'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$migrations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;database_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'migrations/'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// 4.x&lt;/span&gt;
&lt;span class="nv"&gt;$view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;template_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'views/layouts/app.Odo.php'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nv"&gt;$migrations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;schema_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'migrations/'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Search your project for every call. A leftover &lt;code&gt;resource_path()&lt;/code&gt; or &lt;code&gt;database_path()&lt;/code&gt; is a fatal error on 4.x, and I would rather you find it with a search than find it in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Attributes now live next to the thing they configure
&lt;/h3&gt;

&lt;p&gt;In 3.x every attribute we ship lived in one flat namespace, &lt;code&gt;Phaseolies\Utilities\Attributes&lt;/code&gt;. That is a junk drawer. &lt;code&gt;#[Route]&lt;/code&gt; had nothing to do with &lt;code&gt;#[Transaction]&lt;/code&gt;, but they sat side by side.&lt;/p&gt;

&lt;p&gt;In 4.x each attribute moved to the subsystem it belongs to:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attribute&lt;/th&gt;
&lt;th&gt;4.x namespace&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;#[Bind]&lt;/code&gt;, &lt;code&gt;#[Resolver]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Phaseolies\DI\Attributes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#[Transaction]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Phaseolies\Database\Attributes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#[Model]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Phaseolies\Database\Entity\Attributes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#[BindPayload]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Phaseolies\Http\Requests\Attributes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;#[Middleware]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Phaseolies\Middleware\Attributes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;#[Route]&lt;/code&gt;, &lt;code&gt;#[Mapper]&lt;/code&gt;, &lt;code&gt;#[Throttle]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Phaseolies\Support\Router\Attributes&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of these is a namespace move and nothing else. Names, signatures, and behaviour are unchanged. All you do is fix the &lt;code&gt;use&lt;/code&gt; line at the top of each controller, model, and middleware:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 3.x&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Phaseolies\Utilities\Attributes\Route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Phaseolies\Utilities\Attributes\Middleware&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 4.x&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Phaseolies\Support\Router\Attributes\Route&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Phaseolies\Middleware\Attributes\Middleware&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One attribute did not move, it was removed: &lt;code&gt;#[CastToDate]&lt;/code&gt;. It was deprecated in favour of the attribute cast system (&lt;code&gt;#[ToDate]&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. &lt;code&gt;env.toml&lt;/code&gt; replaces &lt;code&gt;.env&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;This is my favourite small change in the release, because it fixes a bug that every PHP developer has hit at least once.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;.env&lt;/code&gt; file only produces strings. &lt;code&gt;APP_DEBUG=false&lt;/code&gt; is not the boolean &lt;code&gt;false&lt;/code&gt;. It is the five-character string &lt;code&gt;"false"&lt;/code&gt;, and in PHP &lt;code&gt;(bool) "false"&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;. So a debug flag you turned off stays on, and you find out when you see a stack trace on a live site.&lt;/p&gt;

&lt;p&gt;4.x reads configuration from &lt;code&gt;env.toml&lt;/code&gt;, and TOML has real types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# env.toml&lt;/span&gt;
&lt;span class="py"&gt;APP_NAME&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Doppar"&lt;/span&gt;
&lt;span class="py"&gt;APP_ENV&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"local"&lt;/span&gt;
&lt;span class="py"&gt;APP_DEBUG&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="py"&gt;APP_URL&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"http://localhost:8000"&lt;/span&gt;

&lt;span class="py"&gt;DB_CONNECTION&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"mysql"&lt;/span&gt;
&lt;span class="py"&gt;DB_PORT&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3306&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;env('APP_DEBUG')&lt;/code&gt; returns an actual &lt;code&gt;bool&lt;/code&gt;. &lt;code&gt;env('DB_PORT')&lt;/code&gt; returns an actual &lt;code&gt;int&lt;/code&gt;. You can stop writing &lt;code&gt;(bool) env(...)&lt;/code&gt; and hoping.&lt;/p&gt;

&lt;p&gt;A few things behave differently from what you're used to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Strings must be quoted.&lt;/strong&gt; &lt;code&gt;APP_ENV = "local"&lt;/code&gt;, not &lt;code&gt;APP_ENV = local&lt;/code&gt;. Booleans and numbers are written bare.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The file must stay flat.&lt;/strong&gt; TOML supports &lt;code&gt;[section]&lt;/code&gt; tables, but every &lt;code&gt;env()&lt;/code&gt; call expects a flat key, so Doppar rejects a nested table with a clear error instead of silently ignoring it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No interpolation.&lt;/strong&gt; &lt;code&gt;MAIL_FROM_NAME="${APP_NAME}"&lt;/code&gt; has no equivalent. Write the literal value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real environment variables still win.&lt;/strong&gt; If &lt;code&gt;APP_DEBUG&lt;/code&gt; is already set at the process level (Docker, systemd, your host's panel), the value in &lt;code&gt;env.toml&lt;/code&gt; is skipped for that key, exactly like &lt;code&gt;.env&lt;/code&gt; always worked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If &lt;code&gt;env.toml&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Remember to update your &lt;code&gt;.gitignore&lt;/code&gt; too: &lt;code&gt;.env&lt;/code&gt; becomes &lt;code&gt;env.toml&lt;/code&gt;, and &lt;code&gt;!.env.example&lt;/code&gt; becomes &lt;code&gt;!env.toml.example&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Symfony Mailer replaces PHPMailer
&lt;/h3&gt;

&lt;p&gt;Mail now runs on &lt;a href="https://symfony.com/doc/current/mailer.html" rel="noopener noreferrer"&gt;Symfony Mailer&lt;/a&gt;. PHPMailer is gone from the framework.&lt;/p&gt;

&lt;p&gt;What does &lt;em&gt;not&lt;/em&gt; change is what you actually write. Mailables keep their contract: &lt;code&gt;subject()&lt;/code&gt;, &lt;code&gt;content()&lt;/code&gt;, &lt;code&gt;attachment()&lt;/code&gt;. The &lt;code&gt;make:mail&lt;/code&gt; stub is the same. If your code only builds mailables and sends them, you may not need to touch it at all.&lt;/p&gt;

&lt;p&gt;What does change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;runtime/config/mail.php&lt;/code&gt; needs updating.&lt;/strong&gt; The &lt;code&gt;qmail&lt;/code&gt; and plain &lt;code&gt;mail&lt;/code&gt; (PHP's &lt;code&gt;mail()&lt;/code&gt;) mailers are gone, because Symfony Mailer doesn't support them. Move to &lt;code&gt;smtp&lt;/code&gt;, &lt;code&gt;sendmail&lt;/code&gt;, or a DSN-based provider bridge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom drivers need a rewrite.&lt;/strong&gt; A class implementing &lt;code&gt;MailDriverInterface&lt;/code&gt; should now be a Symfony &lt;code&gt;TransportInterface&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CC and BCC really deliver now.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The nicest part is the DSN. One value replaces a whole block of host, port, username, and password settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;MAILER_DSN&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"smtp://user:pass@smtp.mailgun.org:587"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;failover&lt;/code&gt; and &lt;code&gt;roundrobin&lt;/code&gt; transports are supported too, if you want more than one provider behind a single mailer.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;composer.json&lt;/code&gt;, swap &lt;code&gt;phpmailer/phpmailer&lt;/code&gt; for &lt;code&gt;symfony/mailer: ^8.1&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Validate DTOs with attributes
&lt;/h3&gt;

&lt;p&gt;This one is new, and I think it is going to change how many of you write controllers.&lt;/p&gt;

&lt;p&gt;You already had &lt;code&gt;#[BindPayload]&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\DTO&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Phaseolies\Validation\Attributes\Between&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Phaseolies\Validation\Attributes\Integer&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Phaseolies\Validation\Attributes\Length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Phaseolies\Validation\Attributes\NotBlank&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Phaseolies\Validation\Attributes\StringType&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;final&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BookData&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;#[NotBlank]&lt;/span&gt;
    &lt;span class="na"&gt;#[StringType]&lt;/span&gt;
    &lt;span class="na"&gt;#[Length(max: 255)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$title&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[NotBlank]&lt;/span&gt;
    &lt;span class="na"&gt;#[Integer]&lt;/span&gt;
    &lt;span class="na"&gt;#[Between(min: 1, max: 5)]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$rating&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then switch validation on where the DTO is bound:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="na"&gt;#[Route(uri: 'books', methods: ['POST'])]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="na"&gt;#[BindPayload(strict: true, validate: true)]&lt;/span&gt;
    &lt;span class="nc"&gt;BookData&lt;/span&gt; &lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;Book&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$book&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;toArray&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/books'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payload is validated &lt;em&gt;before&lt;/em&gt; 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 &lt;code&gt;#[NotBlank]&lt;/code&gt;, &lt;code&gt;#[StringType]&lt;/code&gt;, &lt;code&gt;#[Integer]&lt;/code&gt;, &lt;code&gt;#[Length]&lt;/code&gt;, and &lt;code&gt;#[Between]&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Semantic search with &lt;code&gt;doppar/embeds&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;4.0 ships a new package, &lt;code&gt;doppar/embeds&lt;/code&gt;, that adds semantic search to your models with an attribute.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Phaseolies\Database\Entity\Model&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doppar\Embeds\Attributes\Embeds&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doppar\Embeds\Concerns\Embeddable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Model&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Embeddable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[Embeds]&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$description&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;$results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;whereSimilarTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'description'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'a durable waterproof backpack'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mark a column with &lt;code&gt;#[Embeds]&lt;/code&gt; 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 &lt;code&gt;LIKE&lt;/code&gt; query will never do that.&lt;/p&gt;

&lt;p&gt;Two design decisions I care about. First, embeddings are computed locally through &lt;code&gt;doppar/ai&lt;/code&gt;, 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 &lt;code&gt;brute_force&lt;/code&gt; driver needs no setup and works on every database Doppar supports. When a table gets large, &lt;code&gt;pgvector&lt;/code&gt; and &lt;code&gt;redis&lt;/code&gt; push the ranking into a real HNSW index.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;redis&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. A security pass across the core
&lt;/h3&gt;

&lt;p&gt;I am putting this near the end on purpose, because it is the change that matters most and gets talked about least.&lt;/p&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encryption moved to authenticated AES-256-GCM.&lt;/strong&gt; 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 &lt;code&gt;APP_KEY&lt;/code&gt; was decoded.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Path traversal&lt;/strong&gt; was closed in &lt;code&gt;Storage&lt;/code&gt; and in &lt;code&gt;Support\File&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload validation looks at the real file.&lt;/strong&gt; The &lt;code&gt;mimes&lt;/code&gt; rule now inspects file content, so a script renamed to &lt;code&gt;photo.jpg&lt;/code&gt; and sent with a friendly &lt;code&gt;Content-Type&lt;/code&gt; header no longer passes. It fails closed: if PHP has no way to detect the type (the &lt;code&gt;fileinfo&lt;/code&gt; extension is missing, for example), the file is rejected instead of trusted. Make sure &lt;code&gt;fileinfo&lt;/code&gt; is enabled on your server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL identifier injection&lt;/strong&gt; 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 &lt;code&gt;ASC&lt;/code&gt; or &lt;code&gt;DESC&lt;/code&gt; throws.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mass assignment on &lt;code&gt;update()&lt;/code&gt;&lt;/strong&gt; is now protected the same way as &lt;code&gt;create()&lt;/code&gt;. Updates only write attributes listed in the model's &lt;code&gt;$creatable&lt;/code&gt;, and a model that doesn't declare &lt;code&gt;$creatable&lt;/code&gt; throws a &lt;code&gt;RuntimeException&lt;/code&gt; when you save it. This one can bite during an upgrade, so check your models: if any of them has no &lt;code&gt;$creatable&lt;/code&gt;, add it before you go live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The session ID is regenerated after a successful login&lt;/strong&gt;, which closes the door on session fixation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And a group of ordinary bugs that were not security issues but were bad enough to deserve a fix in a major release: &lt;code&gt;Schema::table()&lt;/code&gt; was generating &lt;code&gt;CREATE TABLE&lt;/code&gt; instead of &lt;code&gt;ALTER TABLE&lt;/code&gt;, a dependency-injection resolution-order bug, &lt;code&gt;Model::__get()&lt;/code&gt; swallowing a &lt;code&gt;\Throwable&lt;/code&gt;, &lt;code&gt;after_created&lt;/code&gt; hooks running in the wrong order, PostgreSQL enum validation, and &lt;code&gt;base_path()&lt;/code&gt; handling of absolute paths on different platforms.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  11. Smaller things, all deliberate
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A redesigned error page.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Request&lt;/code&gt; no longer reads superglobals in its constructor.&lt;/strong&gt; It takes its data as explicit arguments now, and &lt;code&gt;Request::capture()&lt;/code&gt; builds one from the current PHP request. This makes requests far easier to construct in tests. If you create a &lt;code&gt;Request&lt;/code&gt; by hand anywhere, pass the data in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No global &lt;code&gt;BASE_PATH&lt;/code&gt; constant.&lt;/strong&gt; The base path is injected into the application through its constructor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;vendor:publish&lt;/code&gt; asks which launcher to publish&lt;/strong&gt; when there is more than one, instead of guessing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CI runs on PHP 8.5.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What this means for you
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Upgrade PHP to 8.5 or later.&lt;/strong&gt; Nothing else works without it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move the skeleton.&lt;/strong&gt; &lt;code&gt;app/&lt;/code&gt; to &lt;code&gt;src/&lt;/code&gt;, &lt;code&gt;database/&lt;/code&gt; to &lt;code&gt;schema/&lt;/code&gt; (and &lt;code&gt;seeds/&lt;/code&gt; to &lt;code&gt;seeders/&lt;/code&gt;), &lt;code&gt;resources/&lt;/code&gt; to &lt;code&gt;templates/&lt;/code&gt;, &lt;code&gt;config/&lt;/code&gt; to &lt;code&gt;runtime/config/&lt;/code&gt;, &lt;code&gt;routes/&lt;/code&gt; to &lt;code&gt;runtime/routes/&lt;/code&gt;, and fold &lt;code&gt;bootstrap/&lt;/code&gt; into &lt;code&gt;runtime/app.php&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convert every provider to a launcher.&lt;/strong&gt; Move the files, extend &lt;code&gt;ServiceLauncher&lt;/code&gt;, rename &lt;code&gt;boot()&lt;/code&gt; to &lt;code&gt;launch()&lt;/code&gt;, and update the &lt;code&gt;launchers&lt;/code&gt; array in &lt;code&gt;runtime/config/app.php&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rename the path helpers.&lt;/strong&gt; &lt;code&gt;resource_path()&lt;/code&gt; to &lt;code&gt;template_path()&lt;/code&gt;, &lt;code&gt;database_path()&lt;/code&gt; to &lt;code&gt;schema_path()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix the attribute imports&lt;/strong&gt;, and remove any &lt;code&gt;#[CastToDate]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rename &lt;code&gt;Kernel&lt;/code&gt; to &lt;code&gt;Gateway&lt;/code&gt;&lt;/strong&gt; and implement &lt;code&gt;GatewayInterface&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update &lt;code&gt;vendor:publish&lt;/code&gt; flags&lt;/strong&gt; in scripts and docs: &lt;code&gt;--provider&lt;/code&gt; becomes &lt;code&gt;--launcher&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Convert &lt;code&gt;.env&lt;/code&gt; to &lt;code&gt;env.toml&lt;/code&gt;.&lt;/strong&gt; Quote strings, write booleans and numbers bare, flatten any &lt;code&gt;${VAR}&lt;/code&gt; interpolation, and fix &lt;code&gt;.gitignore&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swap the mail transport.&lt;/strong&gt; Remove &lt;code&gt;phpmailer/phpmailer&lt;/code&gt;, add &lt;code&gt;symfony/mailer&lt;/code&gt;, update &lt;code&gt;mail.php&lt;/code&gt;, and rewrite any custom driver.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check your models and your server.&lt;/strong&gt; Every model you save needs a &lt;code&gt;$creatable&lt;/code&gt; list, and file uploads validated with &lt;code&gt;mimes&lt;/code&gt; need the &lt;code&gt;fileinfo&lt;/code&gt; PHP extension.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run your tests.&lt;/strong&gt; CC and BCC delivery changed, and anything that constructs a &lt;code&gt;Request&lt;/code&gt; by hand needs to pass its data in.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The complete step-by-step guide is in the release notes at &lt;a href="https://doppar.com/versions/4.x/releases" rel="noopener noreferrer"&gt;doppar.com/versions/4.x/releases&lt;/a&gt;, and the &lt;a href="https://doppar.com/versions/4.x/installation" rel="noopener noreferrer"&gt;installation guide&lt;/a&gt; is the place to start a fresh project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Support and versioning
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;^4.0&lt;/code&gt; are safe to use.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I hope 4.0 is
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ServiceLauncher&lt;/code&gt;, &lt;code&gt;Gateway&lt;/code&gt;, &lt;code&gt;template_path()&lt;/code&gt;, &lt;code&gt;schema_path()&lt;/code&gt;, &lt;code&gt;runtime/&lt;/code&gt;, &lt;code&gt;env.toml&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  One more thing: Ripple is coming next week
&lt;/h2&gt;

&lt;p&gt;I've been saving this for the last page.&lt;/p&gt;

&lt;p&gt;Next week we are releasing &lt;strong&gt;Ripple&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Here is the whole counter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?php&lt;/span&gt;

&lt;span class="kn"&gt;namespace&lt;/span&gt; &lt;span class="nn"&gt;App\Ripple&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doppar\Ripple\Attributes\Invokable&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kn"&gt;use&lt;/span&gt; &lt;span class="nc"&gt;Doppar\Ripple\Component&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Component&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[Invokable]&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;mixed&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ripple.counter'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;div&amp;gt;
    &amp;lt;p&amp;gt;Count: [[ $count ]]&amp;lt;/p&amp;gt;
    &amp;lt;button type="button" ripple:call="increment"&amp;gt;+&amp;lt;/button&amp;gt;
&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Public properties are the component's state. Methods the browser is allowed to call are marked with &lt;code&gt;#[Invokable]&lt;/code&gt;, and only those. You mount it on any page with one line, or make it the entire page with a route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Route&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;ripple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'/counter'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;\App\Ripple\Counter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'counter'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  How it works
&lt;/h3&gt;

&lt;p&gt;When you click that button, five things happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The browser sends the action and the component's signed state to &lt;code&gt;/ripple/update&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Ripple hydrates the component from that state and runs &lt;code&gt;increment()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It renders the view again.&lt;/li&gt;
&lt;li&gt;It compares the old HTML with the new HTML, and works out the smallest set of changes.&lt;/li&gt;
&lt;li&gt;It sends those changes back and the browser applies them.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;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 &lt;code&gt;public/&lt;/code&gt; for you and you add one line to your layout.&lt;/p&gt;

&lt;h3&gt;
  
  
  Security was a design input, not a patch
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;RIPPLE_SIGNING_KEY&lt;/code&gt; or falling back to your &lt;code&gt;APP_KEY&lt;/code&gt;. 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 (&lt;code&gt;strict&lt;/code&gt;), and in production Ripple throws an error if no signing key is configured, instead of quietly falling back to a development key.&lt;/p&gt;

&lt;p&gt;Two attributes give you finer control. &lt;code&gt;#[Locked]&lt;/code&gt; 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. &lt;code&gt;#[Computed]&lt;/code&gt; on a property makes it available to the view but keeps it out of the state that goes to the browser entirely.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;#[Computed]&lt;/code&gt; for anything the user shouldn't see.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is in the first release
&lt;/h3&gt;

&lt;p&gt;Once you have the basics, the rest is there already:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Directives&lt;/strong&gt; for the things you do on every page: &lt;code&gt;ripple:call&lt;/code&gt;, &lt;code&gt;ripple:bind&lt;/code&gt; (with &lt;code&gt;.live&lt;/code&gt; and &lt;code&gt;.debounce.300&lt;/code&gt; modifiers), &lt;code&gt;ripple:loading&lt;/code&gt;, &lt;code&gt;ripple:confirm&lt;/code&gt;, &lt;code&gt;ripple:poll&lt;/code&gt;, and &lt;code&gt;ripple:navigate&lt;/code&gt; for soft page navigation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lifecycle hooks:&lt;/strong&gt; &lt;code&gt;mount()&lt;/code&gt;, &lt;code&gt;updating()&lt;/code&gt;, &lt;code&gt;updated()&lt;/code&gt;, &lt;code&gt;dehydrate()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation&lt;/strong&gt; with Doppar's own rules. Errors come back in the same round trip and appear in &lt;code&gt;$errors&lt;/code&gt; in your view.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Events&lt;/strong&gt; between components with &lt;code&gt;#[On]&lt;/code&gt; and &lt;code&gt;dispatch()&lt;/code&gt;, plus browser events, so sibling components on a page can react to each other in the same request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query string sync&lt;/strong&gt; with &lt;code&gt;#[QueryString]&lt;/code&gt;, so filters survive a refresh.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pagination&lt;/strong&gt; with a &lt;code&gt;HasPagination&lt;/code&gt; trait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy loading&lt;/strong&gt; with &lt;code&gt;#[Lazy]&lt;/code&gt;. Heavy components show a placeholder first and load when they are needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File uploads&lt;/strong&gt; with temporary storage, previews, and a cleanup command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full-page components&lt;/strong&gt; with &lt;code&gt;Route::ripple()&lt;/code&gt;, plus &lt;code&gt;#[Layout]&lt;/code&gt; and &lt;code&gt;#[Title]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A generator:&lt;/strong&gt; &lt;code&gt;php pool make:ripple Counter&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A testing API&lt;/strong&gt;, which I want to spend a second on.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nc"&gt;Ripple&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'count'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'increment'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'count'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;assertSee&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Count: 1'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Ripple::test()&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it needed 4.0
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;Route::ripple()&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Installation will be this short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require doppar/ripple
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register &lt;code&gt;Doppar\Ripple\RippleLauncher&lt;/code&gt; in your &lt;code&gt;launchers&lt;/code&gt; array, add &lt;code&gt;Ripple::scripts()&lt;/code&gt; to your layout, and run &lt;code&gt;php pool make:ripple Counter&lt;/code&gt;. The full documentation ships with the package.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;




&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Try it, break it, and tell me what you think.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Documentation: &lt;a href="https://doppar.com/versions/4.x/installation" rel="noopener noreferrer"&gt;doppar.com/versions/4.x/installation&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Release notes and upgrade guide: &lt;a href="https://doppar.com/versions/4.x/releases" rel="noopener noreferrer"&gt;doppar.com/versions/4.x/releases&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.c%0A![%20](https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/nyr2edu8kndmavuok4v9.png)om/doppar/framework" rel="noopener noreferrer"&gt;github.com/doppar/framework&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>doppar</category>
      <category>laravel</category>
      <category>symfony</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
