DEV Community

Cover image for Doppar 4 Has Been Released, and Here Is Why It Is Special
Mahedi Hasan
Mahedi Hasan

Posted on

Doppar 4 Has Been Released, and Here Is Why It Is Special

Doppar 4.0 is out today.

I want to start this post with something I haven't said out loud before. Doppar 3.0 was a good framework, but if you put it next to Laravel, a lot of people could not tell where one ended and the other began. Service providers with register() and boot(). An App\Http\Kernel. resource_path() and database_path(). A .env file. An app/ folder. Familiar names have a real upside, because they make a new framework easy to try. They also mean the framework is wearing somebody else's t-shirt.

With 3.0 we wore the Laravel t-shirt. With 4.0 we took it off.

That is the short version of why this release matters to me. The long version is the rest of this post, and it is long, because 4.0 touches almost everything a Doppar developer sees in a day: the folder layout, the bootstrapping classes, the config file, the mailer, the path helpers, the attribute imports at the top of every controller. If you are running 3.x, please read all of it before you upgrade. If you are new to Doppar, this is a good place to start, because the framework finally looks like itself.

Why I wanted to change it

Here is the uncomfortable part. Under the surface, Doppar was never a Laravel clone. Models that keep their own history with #[Temporal] and let you time-travel a query. Services you can freeze with #[Immutable] so nothing mutates them after the container builds them. #[Watches] on a property to react to exactly the field that changed, with the old and new value. Casting done with attributes on the property itself instead of a string array. A queue worker that forks a child per job, so a job that hangs gets killed and the worker stays alive. Attribute-driven routing from day one.

None of that came from anywhere else. But when you opened a 3.x project, the first thing you saw was the vocabulary of another framework, and the vocabulary sets the expectation. People read ServiceProvider and assumed the rest would behave the same. Sometimes it did. Often it didn't, and that was confusing for them and unfair to the framework.

So for 4.0 the rule was simple: if a name, a folder, or a convention exists only because another framework has it, question it. Some things survived. A lot didn't.

The 4.0 changes, one by one

1. PHP 8.5 is the minimum

Doppar 3.x needed PHP 8.3. Doppar 4.x needs PHP 8.5. There is no compatibility mode.

I know that is a hard line, and I thought about it for a while. But every version of PHP we support is a version we have to write around. PHP 8.5 brings the pipe operator, first-class Uri objects, clone() with property overrides, and the #[\NoDiscard] attribute. Some of those we can already lean on, others we will use as they settle in, and none of them would be available to the framework or to your application if we kept an 8.3 floor. Raising the minimum now means we don't carry a compatibility tax for the next several years.

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

2. Providers are now Launchers

This is the biggest architectural change, and the one you will notice first.

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

3.x

<?php

namespace App\Providers;

use Phaseolies\Providers\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        //
    }

    public function boot(): void
    {
        //
    }
}
Enter fullscreen mode Exit fullscreen mode

4.x

<?php

namespace App\Launchers;

use Phaseolies\Launchers\ServiceLauncher;

class AppLauncher extends ServiceLauncher
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register(): void
    {
        //
    }

    /**
     * Launch any application services.
     *
     * @return void
     */
    public function launch(): void
    {
        //
    }
}
Enter fullscreen mode Exit fullscreen mode

The generator follows the rename:

# 3.x
php pool make:provider MyServiceProvider

# 4.x
php pool make:launcher MyLauncher
Enter fullscreen mode Exit fullscreen mode

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

Why rename it at all? Because "boot" tells you nothing. A launcher registers its services, then launches. It is a small change in wording, but when you read a class called AppLauncher with a method called launch(), you know what it is for, and you are not translating from another framework's documentation in your head.

Launchers can also be ghostable, which means the framework registers them lazily the first time one of their declared services is actually resolved, instead of on every request. If a launcher provides something only 5% of your requests need, that 5% is the only place you pay for it.

Package authors, this affects you too. Publishable resources are declared inside launchers now, so the flag on vendor:publish changed:

# 3.x
php pool vendor:publish --provider="Vendor\PackageName\PackageServiceProvider"

# 4.x
php pool vendor:publish --launcher="Vendor\PackageName\PackageLauncher"
Enter fullscreen mode Exit fullscreen mode

If your README still says --provider, your users will hit an unrecognized option error on 4.x.

3. The Kernel is now the Gateway, and the Router stopped extending it

In 3.x, Phaseolies\Support\Router extended your application's App\Http\Kernel. Think about what that means. A class inside the framework inherited from a class that lives in your app. The framework depended on your code, in the wrong direction.

In 4.x that is fixed. App\Http\Kernel becomes App\Http\Gateway, it implements a new GatewayInterface, and the Router depends on that interface instead of extending anything.

// 4.x — src/Http/Gateway.php
namespace App\Http;

use Phaseolies\Middleware\Middleware;
use Phaseolies\Http\Contracts\GatewayInterface;

class Gateway extends Middleware implements GatewayInterface
{
    public array $middleware = [/* ... */];
    public $middlewareGroups = [/* ... */];
    public array $routeMiddleware = [/* ... */];

    public function getGlobalMiddleware(): array
    {
        return $this->middleware;
    }

    public function getMiddlewareGroups(): array
    {
        return $this->middlewareGroups;
    }

    public function getRouteMiddleware(): array
    {
        return $this->routeMiddleware;
    }
}
Enter fullscreen mode Exit fullscreen mode

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

This one is not cosmetic, and I want to be clear about that. Once the router talks to an interface, you can swap the gateway in tests, and packages can build on the router without inheriting from something that belongs to the app. There is one side effect worth knowing about. Something that used to call applyMiddleware() directly on the router now has to go through the gateway. I ran into exactly this while fixing our own profiler package for 4.x, so if you maintain a package that registers global middleware, check for it.

4. A folder structure that belongs to Doppar

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

3.x 4.x
app/ src/
app/Providers/ src/Launchers/
config/ runtime/config/
routes/ runtime/routes/
bootstrap/ runtime/app.php
database/migrations/ schema/migrations/
database/seeds/ schema/seeders/
resources/views/ templates/views/
lang/ templates/lang/

storage/, public/, and tests/ stay where they were. The test folder now ships a BootstrapApplication.php next to TestCase.php.

I like this layout for a specific reason. Open a fresh 4.x project and each top-level folder answers one question. What is my code? src/. What does the framework need at runtime to start? runtime/. What describes my database? schema/. What does the user see? templates/. In the old layout config, routes, and bootstrap were three separate folders for what is really one concern.

Two path helpers changed to match, and they have no deprecated alias:

// 3.x
$view = resource_path('views/layouts/app.Odo.php');
$migrations = database_path('migrations/');

// 4.x
$view = template_path('views/layouts/app.Odo.php');
$migrations = schema_path('migrations/');
Enter fullscreen mode Exit fullscreen mode

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

5. Attributes now live next to the thing they configure

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

In 4.x each attribute moved to the subsystem it belongs to:

Attribute 4.x namespace
#[Bind], #[Resolver] Phaseolies\DI\Attributes
#[Transaction] Phaseolies\Database\Attributes
#[Model] Phaseolies\Database\Entity\Attributes
#[BindPayload] Phaseolies\Http\Requests\Attributes
#[Middleware] Phaseolies\Middleware\Attributes
#[Route], #[Mapper], #[Throttle] Phaseolies\Support\Router\Attributes

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

// 3.x
use Phaseolies\Utilities\Attributes\Route;
use Phaseolies\Utilities\Attributes\Middleware;

// 4.x
use Phaseolies\Support\Router\Attributes\Route;
use Phaseolies\Middleware\Attributes\Middleware;
Enter fullscreen mode Exit fullscreen mode

One attribute did not move, it was removed: #[CastToDate]. It was deprecated in favour of the attribute cast system (#[ToDate] and friends), and in a major version we finish what we announced. If any of your models still use it, delete the import and the line.

6. env.toml replaces .env

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

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

4.x reads configuration from env.toml, and TOML has real types:

# env.toml
APP_NAME = "Doppar"
APP_ENV = "local"
APP_DEBUG = false
APP_URL = "http://localhost:8000"

DB_CONNECTION = "mysql"
DB_PORT = 3306
Enter fullscreen mode Exit fullscreen mode

env('APP_DEBUG') returns an actual bool. env('DB_PORT') returns an actual int. You can stop writing (bool) env(...) and hoping.

A few things behave differently from what you're used to:

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

If env.toml is missing entirely, Doppar still boots and falls back to whatever real environment variables exist. That is handy for containers where configuration is injected and no file is shipped.

Remember to update your .gitignore too: .env becomes env.toml, and !.env.example becomes !env.toml.example.

7. Symfony Mailer replaces PHPMailer

Mail now runs on Symfony Mailer. PHPMailer is gone from the framework.

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

What does change:

  • runtime/config/mail.php needs updating. The qmail and plain mail (PHP's mail()) mailers are gone, because Symfony Mailer doesn't support them. Move to smtp, sendmail, or a DSN-based provider bridge.
  • Custom drivers need a rewrite. A class implementing MailDriverInterface should now be a Symfony TransportInterface.
  • CC and BCC really deliver now. Under the old behaviour they were not always handed to the SMTP server as real envelope recipients. If anything downstream relied on that, check it.

The nicest part is the DSN. One value replaces a whole block of host, port, username, and password settings:

MAILER_DSN = "smtp://user:pass@smtp.mailgun.org:587"
Enter fullscreen mode Exit fullscreen mode

failover and roundrobin transports are supported too, if you want more than one provider behind a single mailer.

In composer.json, swap phpmailer/phpmailer for symfony/mailer: ^8.1.

8. Validate DTOs with attributes

This one is new, and I think it is going to change how many of you write controllers.

You already had #[BindPayload] to turn a request into a typed DTO. In 4.x you can also say what a valid payload looks like, on the properties themselves:

<?php

namespace App\DTO;

use Phaseolies\Validation\Attributes\Between;
use Phaseolies\Validation\Attributes\Integer;
use Phaseolies\Validation\Attributes\Length;
use Phaseolies\Validation\Attributes\NotBlank;
use Phaseolies\Validation\Attributes\StringType;

final class BookData
{
    #[NotBlank]
    #[StringType]
    #[Length(max: 255)]
    public string $title;

    #[NotBlank]
    #[Integer]
    #[Between(min: 1, max: 5)]
    public int $rating;
}
Enter fullscreen mode Exit fullscreen mode

Then switch validation on where the DTO is bound:

#[Route(uri: 'books', methods: ['POST'])]
public function store(
    #[BindPayload(strict: true, validate: true)]
    BookData $book,
) {
    Book::create($book->toArray());

    return redirect('/books');
}
Enter fullscreen mode Exit fullscreen mode

The payload is validated before the DTO is hydrated and handed to your controller. If the input is bad, your method never runs. The rules live next to the data they protect, so when someone changes the DTO they see the rules in the same file. The built-in constraints are #[NotBlank], #[StringType], #[Integer], #[Length], and #[Between].

9. Semantic search with doppar/embeds

4.0 ships a new package, doppar/embeds, that adds semantic search to your models with an attribute.

use Phaseolies\Database\Entity\Model;
use Doppar\Embeds\Attributes\Embeds;
use Doppar\Embeds\Concerns\Embeddable;

class Product extends Model
{
    use Embeddable;

    #[Embeds]
    protected $description;
}

$results = Product::whereSimilarTo('description', 'a durable waterproof backpack', limit: 10);
Enter fullscreen mode Exit fullscreen mode

Mark a column with #[Embeds] and Doppar keeps a numeric representation of its meaning, a vector, in sync as records change. A search for "a durable waterproof backpack" then finds "rugged daypack for hiking in the rain", even though the two share almost no words. A plain LIKE query will never do that.

Two design decisions I care about. First, embeddings are computed locally through doppar/ai, so there is no external API call, no API key, and no per-request bill. Second, storage and ranking are handled by a swappable driver. The default brute_force driver needs no setup and works on every database Doppar supports. When a table gets large, pgvector and redis push the ranking into a real HNSW index.

All three drivers have been tested end to end against real data, including checking that the ranking results made sense and not only that the code ran. If you use the redis driver, read the package documentation first: it needs Redis Stack with the search module, and the module has a minimum Redis version. Loading it into an older Redis can take the server down. That is written down in the docs so you don't find out the hard way.

10. A security pass across the core

I am putting this near the end on purpose, because it is the change that matters most and gets talked about least.

While preparing 4.0 we went through the framework looking for the kind of problems that only show up when someone hostile reads your code. We found some, and fixed them:

  • Encryption moved to authenticated AES-256-GCM. New data is encrypted with an AEAD cipher, so tampering is detected instead of silently decrypting to garbage. Data encrypted with the old AES-256-CBC format still decrypts, so you don't lose anything. Along the way we fixed a bug in how the APP_KEY was decoded.
  • Path traversal was closed in Storage and in Support\File.
  • Upload validation looks at the real file. The mimes rule now inspects file content, so a script renamed to photo.jpg and sent with a friendly Content-Type header no longer passes. It fails closed: if PHP has no way to detect the type (the fileinfo extension is missing, for example), the file is rejected instead of trusted. Make sure fileinfo is enabled on your server.
  • SQL identifier injection was closed in the query builder. Column names and sort directions that reach the builder are now validated, and a sort direction that isn't ASC or DESC throws.
  • Mass assignment on update() is now protected the same way as create(). Updates only write attributes listed in the model's $creatable, and a model that doesn't declare $creatable throws a RuntimeException when you save it. This one can bite during an upgrade, so check your models: if any of them has no $creatable, add it before you go live.
  • The session ID is regenerated after a successful login, which closes the door on session fixation.

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

If you're on 3.x and cannot upgrade yet, take security seriously and follow the support policy below. If you can upgrade, this alone is a good reason to.

11. Smaller things, all deliberate

  • A redesigned error page. It shows the message, the request (method, URL, date, status code), and a code frame with the failing line highlighted, along with a light and dark toggle and a copy button.
  • Request no longer reads superglobals in its constructor. It takes its data as explicit arguments now, and Request::capture() builds one from the current PHP request. This makes requests far easier to construct in tests. If you create a Request by hand anywhere, pass the data in.
  • No global BASE_PATH constant. The base path is injected into the application through its constructor.
  • vendor:publish asks which launcher to publish when there is more than one, instead of guessing.
  • CI runs on PHP 8.5.

What this means for you

If you have a 3.x application, the upgrade touches every layer of it, and I won't pretend otherwise. The changes are mostly mechanical, but they are everywhere. Here is the order I would do it in. Each step assumes the one before is finished.

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

Do all of this on a dedicated branch, and don't mix it with feature work. The launcher rename and the folder move are mechanical, but they touch nearly every file. A clean diff is much easier to review than a mixed one.

The complete step-by-step guide is in the release notes at doppar.com/versions/4.x/releases, and the installation guide is the place to start a fresh project.

Support and versioning

Doppar ships one major version a year, with minor and patch releases as needed in between. Minor releases don't break anything. Patch releases are bug fixes and security fixes. Constraints like ^4.0 are safe to use.

Each major version gets bug fixes for 18 months and security fixes for 24 months. 3.x keeps receiving bug fixes until June 1, 2027 and security fixes until December 1, 2027, so nobody is being forced to upgrade this week. The exact dates for 4.x are in the release notes table.

If you are starting something new, start on 4.0. If you're on 3.x, you have time, and I would use it to plan the upgrade properly instead of rushing it.


What I hope 4.0 is

I said at the top that we took off the Laravel t-shirt. I should be fair about it: I have nothing against the framework that shaped how a generation of PHP developers work. Its ideas made PHP better for everyone, including us. But a framework that only feels familiar has no reason of its own to exist. It needs its own words for things, its own layout, its own opinions, and a reason for you to choose it.

ServiceLauncher, Gateway, template_path(), schema_path(), runtime/, env.toml. These are small words. Together they say: this is Doppar, it has its own way of doing things, and it is confident enough to say so.

Thank you to everyone who filed an issue, sent a pull request, or asked a hard question in the last year. A lot of what is in 4.0 started as somebody saying "this doesn't make sense." You were right.


One more thing: Ripple is coming next week

I've been saving this for the last page.

Next week we are releasing Ripple, a new first-party package that lets you build interactive interfaces in PHP, without writing a JavaScript application on top of your Doppar app. I'll go through how it works here, because I've been reading the source this week and I'm excited about it.

If you've used a server-driven UI library before, you will recognise the shape. The idea is not new, and I won't pretend it is. What's ours is how it is built, and that it is a native Doppar package rather than a port.

You write a component as a PHP class and a view in Odo. The browser calls actions on it. Ripple runs your PHP, re-renders on the server, and sends back only the parts of the page that changed.

Here is the whole counter:

<?php

namespace App\Ripple;

use Doppar\Ripple\Attributes\Invokable;
use Doppar\Ripple\Component;

class Counter extends Component
{
    public int $count = 0;

    #[Invokable]
    public function increment(): void
    {
        $this->count++;
    }

    public function render(): mixed
    {
        return view('ripple.counter');
    }
}
Enter fullscreen mode Exit fullscreen mode
<div>
    <p>Count: [[ $count ]]</p>
    <button type="button" ripple:call="increment">+</button>
</div>
Enter fullscreen mode Exit fullscreen mode

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

Route::ripple('/counter', \App\Ripple\Counter::class)->name('counter');
Enter fullscreen mode Exit fullscreen mode

How it works

When you click that button, five things happen:

  1. The browser sends the action and the component's signed state to /ripple/update.
  2. Ripple hydrates the component from that state and runs increment().
  3. It renders the view again.
  4. It compares the old HTML with the new HTML, and works out the smallest set of changes.
  5. It sends those changes back and the browser applies them.

Step 4 is the interesting part. Ripple parses the rendered HTML into a lightweight virtual tree, walks the old tree and the new tree together, and produces a list of operations: replace a node, update some attributes, set some text, insert, remove, reorder. Only that list goes over the wire. Many tools in this space send the new HTML and let the browser figure out what changed. Ripple does the diff on the server, so the browser's job is just to apply a short list of instructions. The JavaScript client is about 12 KB minified, and you never have to build or configure it. Ripple copies it into public/ for you and you add one line to your layout.

Security was a design input, not a patch

State travels through the browser, so it has to be treated as untrusted. Ripple signs every component's state with an HMAC-SHA256 signature, using RIPPLE_SIGNING_KEY or falling back to your APP_KEY. On every request the signature is checked before anything is hydrated, and a request with a missing or wrong signature is rejected. That is the default (strict), and in production Ripple throws an error if no signing key is configured, instead of quietly falling back to a development key.

Two attributes give you finer control. #[Locked] on a property means the client can never change it, which is what you want for an order ID or a user ID that the server assigned. #[Computed] on a property makes it available to the view but keeps it out of the state that goes to the browser entirely.

One honest note: the state is signed, but it is not encrypted. The browser can read it, it just can't change it. So don't keep secrets in public properties. Use #[Computed] for anything the user shouldn't see.

What is in the first release

Once you have the basics, the rest is there already:

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

Ripple::test() mounts the component and sends real update payloads through the same path the browser uses, including the signed state. You get a fluent assertion API for properties, rendered HTML, validation errors, redirects, and events. Ripple's own package has over a hundred tests, and I want yours to be that easy to write.

Why it needed 4.0

Ripple requires PHP 8.5 and Doppar 4.x, and this is a good example of why the changes in this release were worth making. Ripple has its own router that adds Route::ripple(), and it swaps that router in when the package registers. It can only do that cleanly because in 4.0 the router depends on a gateway interface instead of inheriting from your app's Kernel. It installs as a launcher. Its files live in the new layout. In 3.x this would have been a fragile hack. In 4.x it is a normal package.

That is what I mean when I say 4.0 is a foundation. The launcher, gateway, and layout work in this release is what lets the first-party packages sit on top of the framework properly, and Ripple is the first one that really shows it.

Installation will be this short:

composer require doppar/ripple
Enter fullscreen mode Exit fullscreen mode

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

It is a first release, so there will be rough edges, and I'd rather hear about them from you early than late. Next week, try it and tell us what breaks.


Doppar 4.0 is the release where the framework stops borrowing a name for everything and starts using its own. I hope you like it as much as I enjoyed building it.

Try it, break it, and tell me what you think.

Top comments (0)