<?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: ΠR</title>
    <description>The latest articles on DEV Community by ΠR (@pierree).</description>
    <link>https://dev.to/pierree</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: ΠR</title>
      <link>https://dev.to/pierree</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pierree"/>
    <language>en</language>
    <item>
      <title>How Doppar Will Solve Your PHP Problem and Can Be Your Go-To Framework</title>
      <dc:creator>ΠR</dc:creator>
      <pubDate>Sun, 18 Oct 2026 13:59:11 +0000</pubDate>
      <link>https://dev.to/pierree/how-doppar-will-solve-your-php-problem-and-can-be-your-go-to-framework-5920</link>
      <guid>https://dev.to/pierree/how-doppar-will-solve-your-php-problem-and-can-be-your-go-to-framework-5920</guid>
      <description>&lt;p&gt;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".&lt;/p&gt;

&lt;p&gt;You can solve each of these. The question is how much code, how many packages and how many late nights it costs you.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;defer()&lt;/code&gt; to a colleague.&lt;/p&gt;

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

&lt;p&gt;Doppar 4.x needs PHP 8.5. Where a feature arrived in a specific release, I say so.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: The features that make Doppar different
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Temporal ORM: ask what a record looked like last month
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; 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.&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\Temporal\Attributes\Temporal&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Temporal]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Contract&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="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'contracts'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$creatable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'title'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'amount'&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;Run &lt;code&gt;php pool migrate:temporal&lt;/code&gt; once to create the history table. After that:&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="nv"&gt;$then&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Contract&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2024-01-01'&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;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// the contract as it was&lt;/span&gt;
&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$then&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                                &lt;span class="c1"&gt;// 'draft'&lt;/span&gt;

&lt;span class="nv"&gt;$diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$contract&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2024-01-01'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'2024-06-01'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ['changes' =&amp;gt; ['status' =&amp;gt; ['from' =&amp;gt; 'draft', 'to' =&amp;gt; 'active']], ...]&lt;/span&gt;

&lt;span class="nv"&gt;$contract&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;restoreTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'2024-01-01'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                &lt;span class="c1"&gt;// roll it back, and the restore is recorded too&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every change goes through the model's normal &lt;code&gt;save()&lt;/code&gt;, so the restore is itself recorded as a new entry in the history.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Frozen services: configuration that cannot be changed behind your back
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; Mark a service with &lt;code&gt;#[Immutable]&lt;/code&gt;. You configure it while the app boots, and from the first time the container hands it out, any write to it throws an exception.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; Singletons that hold settings (a payment gateway, a tax rate) are shared by the whole process. One careless &lt;code&gt;$payment-&amp;gt;taxRate = 0&lt;/code&gt; 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.&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;#[Immutable]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PaymentService&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;EnforcesImmutability&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;string&lt;/span&gt; &lt;span class="nv"&gt;$gateway&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'stripe'&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;float&lt;/span&gt;  &lt;span class="nv"&gt;$taxRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.08&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 php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// In a launcher, while the app boots&lt;/span&gt;
&lt;span class="nv"&gt;$payment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PaymentService&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nv"&gt;$payment&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;gateway&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'payment.gateway'&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;app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;singleton&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PaymentService&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="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$payment&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Later, anywhere in your app:&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="nv"&gt;$payment&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// fine&lt;/span&gt;
&lt;span class="nv"&gt;$payment&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;taxRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// throws ImmutableViolationException&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading, method calls and &lt;code&gt;isset()&lt;/code&gt; always work. Writes, &lt;code&gt;unset()&lt;/code&gt; and &lt;code&gt;clone&lt;/code&gt; throw. Freezing closure and &lt;code&gt;instance()&lt;/code&gt; services arrived in Doppar 4.7.0.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Reactive models: react to one property, not the whole save
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; &lt;code&gt;#[Watches]&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; "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.&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Order&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="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$creatable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'total'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="na"&gt;#[Watches(OrderStatusChanged::class)]&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$status&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 php"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrderStatusChanged&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;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="nv"&gt;$old&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;mixed&lt;/span&gt; &lt;span class="nv"&gt;$new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;Model&lt;/span&gt; &lt;span class="nv"&gt;$model&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$new&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'shipped'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="c1"&gt;// tell the customer&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;You can stack several watchers on one property, watch several properties, and make a watch conditional.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Semantic search with one attribute, and no API key
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; The &lt;code&gt;doppar/embeds&lt;/code&gt; package adds &lt;code&gt;#[Embeds]&lt;/code&gt;. Mark a column, and Doppar keeps a numeric representation of its &lt;em&gt;meaning&lt;/em&gt; up to date every time you save. You then search by meaning instead of by words.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; &lt;code&gt;LIKE '%backpack%'&lt;/code&gt; 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.&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="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="k"&gt;protected&lt;/span&gt; &lt;span class="nv"&gt;$creatable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'name'&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&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;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: &lt;code&gt;brute_force&lt;/code&gt;, &lt;code&gt;pgvector&lt;/code&gt; and &lt;code&gt;redis&lt;/code&gt;. Note that it needs PHP's FFI extension turned on for persistent servers like PHP-FPM, and the docs explain how.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. &lt;code&gt;#[Transaction]&lt;/code&gt;: wrap a controller action in a transaction with one line
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; An attribute that runs your controller method inside a database transaction, with optional retries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; Most "create an order and its items" actions need a transaction. Writing &lt;code&gt;DB::transaction(...)&lt;/code&gt; around the whole body is easy to forget and makes the code nest deeper.&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\Attributes\Transaction&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="na"&gt;#[Transaction(attempts: 3)]&lt;/span&gt;
&lt;span class="na"&gt;#[Route(uri: 'orders', 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="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Order&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;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;all&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the method throws, the transaction rolls back. With &lt;code&gt;attempts: 3&lt;/code&gt;, a transient failure such as a deadlock is retried up to three times. You can also pick a connection: &lt;code&gt;#[Transaction(connection: 'pgsql')]&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Templates where you choose the syntax
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; If a project already uses a syntax that clashes with &lt;code&gt;{{ }}&lt;/code&gt; (some JavaScript frameworks do), or you simply prefer another style, you change a config value and carry on. Output is escaped by default.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;#if ($user)
    &lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;[[ $user-&amp;gt;name ]]&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
#endif

#foreach ($posts as $post)
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;[[ $post-&amp;gt;title ]]&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
#endforeach
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't like &lt;code&gt;#&lt;/code&gt; for directives? Set &lt;code&gt;'directive_prefix' =&amp;gt; '@'&lt;/code&gt; in &lt;code&gt;runtime/config/odo.php&lt;/code&gt; and write &lt;code&gt;@if&lt;/code&gt; and &lt;code&gt;@foreach&lt;/code&gt; instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. A Bloom filter you can call from your code
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; The &lt;code&gt;doppar/bloom&lt;/code&gt; package gives you a Bloom filter backed by Redis. It answers "is this probably in the set?" using a small fixed amount of memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; "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".&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="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Bloom&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email'&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;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// probably seen before&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// definitely new&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nc"&gt;Bloom&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'email'&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;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'someone@example.com'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  8. A queue that survives crashes and kills stuck jobs
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; &lt;code&gt;doppar/queue&lt;/code&gt; runs your background jobs on the database or on Redis. It has priorities, unique jobs, retry backoff, and a lease system for recovery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; 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 &lt;em&gt;lease&lt;/em&gt; 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 &lt;code&gt;timeout&lt;/code&gt; runs in its own process, so a stuck job gets killed instead of hanging the worker.&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;#[Queueable(timeout: 60, priority: 90)]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SendPasswordResetJob&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Job&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;__construct&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;User&lt;/span&gt; &lt;span class="nv"&gt;$user&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;handle&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;// runs in its own process, stopped after 60 seconds&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;Give a job a key and a second copy is refused while the first one waits or runs:&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;uniqueId&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;?string&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;string&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;reportId&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;Because a job can run twice after a crash, write jobs so that running them again is safe. The docs say the same.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: Features that make everyday work better
&lt;/h2&gt;

&lt;h3&gt;
  
  
  9. Model hooks: put model rules next to the model
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; Attach small methods to moments in a model's life: before it is created, after it is deleted, and so on. Use the &lt;code&gt;#[Hook]&lt;/code&gt; attribute on a method.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; Slugs, default values, cache clearing and audit records belong with the model, not scattered across controllers where one of them will forget.&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Post&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="na"&gt;#[Hook('before_created')]&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;generateSlug&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;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&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;slug&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;title&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="na"&gt;#[Hook('before_updated', when: 'isBeingPublished')]&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;stampPublishedAt&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;published_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;now&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;isBeingPublished&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;bool&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="nf"&gt;isDirtyAttr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'published'&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;There are ten events. If you need to write without hooks (an import, a data fix), &lt;code&gt;$post-&amp;gt;withoutHook()-&amp;gt;save()&lt;/code&gt; skips them for one write. In 4.7.0, &lt;code&gt;withHooks()&lt;/code&gt; was added for the opposite case: &lt;code&gt;Post::where('status', 'draft')-&amp;gt;withHooks()-&amp;gt;update([...])&lt;/code&gt; runs the hooks for every record in a bulk update.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. Execution context: one identity for every request, command and job
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; Every piece of work in Doppar (an HTTP request, a console command, a queue job) runs inside an &lt;em&gt;execution&lt;/em&gt; with its own id, type and metadata. Services can be tied to that execution, so they disappear with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; 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.&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;Execution&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                       &lt;span class="c1"&gt;// "9f2c41e07b1d3a58"&lt;/span&gt;
&lt;span class="nc"&gt;Execution&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                     &lt;span class="c1"&gt;// "request"&lt;/span&gt;

&lt;span class="nc"&gt;Execution&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;stamp&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s1"&gt;'tenant'&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'acme'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="nc"&gt;Log&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Payment failed'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;          &lt;span class="c1"&gt;// the log entry carries the id, type and tenant&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For per-request state, bind the service to the execution scope, and it can never be seen by the next request:&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="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bindExecution&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;TenantState&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="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TenantState&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This arrived in Doppar 4.6.0.&lt;/p&gt;

&lt;h3&gt;
  
  
  11. &lt;code&gt;defer()&lt;/code&gt;: clean up right where you create the mess
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; &lt;code&gt;defer()&lt;/code&gt; works like Go's &lt;code&gt;defer&lt;/code&gt;. You register a cleanup callback right after you acquire something, and Doppar runs it when the current execution ends, whether it succeeded or threw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;finally&lt;/code&gt; puts the release at the bottom, far from the acquire. Add a second resource and you nest another level.&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="nv"&gt;$lock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cache&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;locked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'report:'&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nv"&gt;$lock&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;\RuntimeException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Already running.'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;defer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$lock&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="nv"&gt;$report&lt;/span&gt; &lt;span class="o"&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="nf"&gt;generateReport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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, &lt;code&gt;kill -9&lt;/code&gt;, a power cut), no PHP code runs, so give your locks their own expiry too. This arrived in 4.6.0.&lt;/p&gt;

&lt;h3&gt;
  
  
  12. Signals: events that wait for the commit
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; Nobody wants a receipt email for a payment that was rolled back.&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;#[DeliverOnSettle]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;InvoicePaid&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;__construct&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;readonly&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nv"&gt;$invoiceId&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 php"&gt;&lt;code&gt;&lt;span class="no"&gt;DB&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;transaction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;use&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;markPaid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="nc"&gt;Signal&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;InvoicePaid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$invoice&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// held until the commit&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;php pool make:signal InvoicePaid --settle&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  13. Routing, binding and rate limits as attributes
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; Routes, route-model binding and throttling sit on the controller method, in plain sight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; You read the method and see everything about it: its URL, its limits and what it loads.&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: 'login', methods: ['POST'], rateLimit: 10, rateLimitDecay: 1)]&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;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&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="na"&gt;#[Route('/profile/{user}', methods: ['GET'], where: ['user' =&amp;gt; '[0-9]+'])]&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;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="nv"&gt;$user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// /profile/5 loads the user; /profile/abc is a 404 and never queries the database&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;#[Model('email')]&lt;/code&gt; binds on another column. There is also a &lt;code&gt;#[Throttle(maxAttempts: 10, decayMinutes: 1)]&lt;/code&gt; attribute, and &lt;code&gt;#[Mapper]&lt;/code&gt; for prefixes and middleware on a whole controller. If you prefer a routes file, the &lt;code&gt;Route&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  14. Validate a request with a plain class
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; Describe your input as a small class with attributes, and Doppar validates the request before your controller code runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; Your controller receives clean, typed data, or the request is rejected. There is no validation code in the action itself.&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="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;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="err"&gt;#&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;BindPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strict&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;validate&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)]&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="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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  15. Soft deletes and casts, without traits
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; Two common model features, switched on with attributes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; No trait to remember, no global scope to register, and the setup is visible at a glance.&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;#[SoftDeletes]&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Post&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="na"&gt;#[ToBoolean]&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$is_featured&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="na"&gt;#[ToDateTime]&lt;/span&gt;
    &lt;span class="k"&gt;protected&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="nv"&gt;$published_at&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;With &lt;code&gt;#[SoftDeletes]&lt;/code&gt;, &lt;code&gt;delete()&lt;/code&gt; marks the row as deleted, and every query leaves trashed rows out unless you ask: &lt;code&gt;withTrashed()&lt;/code&gt;, &lt;code&gt;onlyTrashed()&lt;/code&gt;, &lt;code&gt;restore()&lt;/code&gt;, &lt;code&gt;forceDelete()&lt;/code&gt;. Casts work on read and write, so you stop scattering &lt;code&gt;(bool)&lt;/code&gt; and &lt;code&gt;json_decode()&lt;/code&gt; through your code.&lt;/p&gt;

&lt;h3&gt;
  
  
  16. Relationships with names that say what they do
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; Doppar names relationships by what they do: &lt;code&gt;linkOne&lt;/code&gt;, &lt;code&gt;linkMany&lt;/code&gt;, &lt;code&gt;bindTo&lt;/code&gt; for the inverse, and &lt;code&gt;bindToMany&lt;/code&gt; for many-to-many. You load them with &lt;code&gt;embed()&lt;/code&gt;, and you can choose the columns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; Eager loading without the N+1 problem, and without loading columns you don't need.&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="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&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="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;()&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="nf"&gt;linkOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Otp&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="s1"&gt;'user_id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'id'&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 php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;find&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="n"&gt;otp&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// lazy loading&lt;/span&gt;

&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;query&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;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'otp'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$q&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'id'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'otp'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'user_id'&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;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                   &lt;span class="c1"&gt;// one extra query, only those columns&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can ask the other way too: &lt;code&gt;User::present('otp')&lt;/code&gt; finds users that have one, &lt;code&gt;User::absent('otp')&lt;/code&gt; finds those that don't, and &lt;code&gt;embedCount()&lt;/code&gt; returns counts without loading the records. For many-to-many, &lt;code&gt;link()&lt;/code&gt;, &lt;code&gt;unlink()&lt;/code&gt; and &lt;code&gt;relate()&lt;/code&gt; manage the pivot table for you.&lt;/p&gt;

&lt;h3&gt;
  
  
  17. Stream collections: process a huge file without running out of memory
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; A lazy collection built on generators. Each step (&lt;code&gt;map&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;take&lt;/code&gt;) runs only when you actually read the data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; 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.&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="nv"&gt;$errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StreamCollection&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;storage_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'logs/app.log'&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;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;fn&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&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;str_contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ERROR'&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;take&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&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;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file is opened when you start reading and closed when the stream ends, or when you stop early, as &lt;code&gt;take(10)&lt;/code&gt; does here.&lt;/p&gt;

&lt;h3&gt;
  
  
  18. Run system commands without opening a security hole
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; &lt;code&gt;doppar/orion&lt;/code&gt; runs shell commands, in sequence, in parallel pools, or piped into each other. It is built on Symfony Process.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; Running commands from PHP is a classic place for command injection. Orion never runs your command through a shell, so &lt;code&gt;$HOME&lt;/code&gt;, &lt;code&gt;*&lt;/code&gt;, &lt;code&gt;;&lt;/code&gt; and &lt;code&gt;|&lt;/code&gt; are just text.&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="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Process&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;ping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'git commit -m "fix the bug"'&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;execute&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;$result&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getOutput&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nc"&gt;Process&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;ping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'echo $HOME'&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;execute&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;getOutput&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// "$HOME", the variable is not expanded&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When an argument has awkward characters, pass an array and each element goes to the program exactly as it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  19. A profiler built into your development loop
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; &lt;code&gt;doppar/insight&lt;/code&gt; adds a toolbar to the page you are working on, plus a history of recent requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; 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.&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/insight
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register its launcher in &lt;code&gt;runtime/config/app.php&lt;/code&gt; and you are done. It is meant for development, so don't leave it switched on in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  20. AI that runs on your own server
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;What it is.&lt;/strong&gt; &lt;code&gt;doppar/ai&lt;/code&gt; has two parts. &lt;code&gt;Pipeline&lt;/code&gt; runs machine-learning tasks locally (sentiment analysis, text generation and more, over 15 tasks in all) using models from Hugging Face. &lt;code&gt;Agent&lt;/code&gt; talks to cloud models such as OpenAI or Gemini.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why you'd use it.&lt;/strong&gt; 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.&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="nv"&gt;$result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Pipeline&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TaskEnum&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SENTIMENT_ANALYSIS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'I absolutely love this product! Best purchase ever!'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// [['label' =&amp;gt; 'POSITIVE', 'score' =&amp;gt; 0.9998]]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="nv"&gt;$answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;using&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;OpenAI&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;withKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'OPENAI_API_KEY'&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;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'gpt-3.5-turbo'&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;prompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Explain quantum computing in simple terms'&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;send&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  A few smaller things that add up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;env.toml&lt;/code&gt; instead of &lt;code&gt;.env&lt;/code&gt;.&lt;/strong&gt; Your configuration file has real types: booleans and numbers are not strings that you cast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;php pool&lt;/code&gt;.&lt;/strong&gt; One console for everything, with generators such as &lt;code&gt;make:job&lt;/code&gt;, &lt;code&gt;make:signal&lt;/code&gt;, &lt;code&gt;make:watcher&lt;/code&gt;, &lt;code&gt;make:hook&lt;/code&gt; and &lt;code&gt;make:presenter&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API presenters.&lt;/strong&gt; A dedicated class that decides what your JSON looks like, so your models never leak columns by accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Launchers.&lt;/strong&gt; In 4.x, service providers became launchers: &lt;code&gt;register()&lt;/code&gt; and &lt;code&gt;launch()&lt;/code&gt;, in &lt;code&gt;src/Launchers&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed.&lt;/strong&gt; The Doppar docs report about 2,100 requests per second on a tiny endpoint (one &lt;code&gt;User::find(1)&lt;/code&gt;) 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So, should it be your go-to framework?
&lt;/h2&gt;

&lt;p&gt;Here is my honest take.&lt;/p&gt;

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

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

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

&lt;p&gt;The full documentation is at &lt;a href="https://doppar.com/versions/4.x/installation" rel="noopener noreferrer"&gt;https://doppar.com/versions/4.x/installation&lt;/a&gt;, and you can see what's new in 4.x at &lt;a href="https://doppar.com/versions/4.x/releases" rel="noopener noreferrer"&gt;https://doppar.com/versions/4.x/releases&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>doppar</category>
      <category>laravel</category>
      <category>php</category>
    </item>
    <item>
      <title>Doppar 4 Has Been Released, and Here Is Why It Is Special</title>
      <dc:creator>ΠR</dc:creator>
      <pubDate>Sun, 20 Sep 2026 17:28:06 +0000</pubDate>
      <link>https://dev.to/pierree/doppar-4-has-been-released-and-here-is-why-it-is-special-mb5</link>
      <guid>https://dev.to/pierree/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>
