Skip to content

Build your first Pulsar feature

You will turn the registered Client Service into a working POST /api/client/orders endpoint that validates, stores, and returns one order. The same steps run on Laravel 12 and 13.

Before you begin

Start in the supported application root after completing Create your first Service. Pulsar v0.4.1 is installed, vendor/bin/pulsar install has run, and the ClientServiceProvider appears once in bootstrap/providers.php. Composer's existing App\\ to app/ mapping already covers the classes below; this path needs no autoload refresh.

This tutorial deliberately exposes an unauthenticated endpoint and models an order with only a customer name and integer total. Those choices keep the architecture visible; they are not production security or order-modelling advice.

Checkpoint 1: Generate the structure

Run all eight commands from the registered application root in this order. Domain types require the Orders Domain, while Service types require the existing Client Service. The plain Controller and single Resource need no options, and the commands ask no questions.

Context: registered application root — Generate the Orders vertical slice in dependency order.

bash
vendor/bin/pulsar make:domain Orders
vendor/bin/pulsar make:dto CreateOrderData Orders
vendor/bin/pulsar make:model Order Orders
vendor/bin/pulsar make:action CreateOrder Orders
vendor/bin/pulsar make:use-case CreateOrder Orders Client
vendor/bin/pulsar make:request StoreOrderRequest Orders Client
vendor/bin/pulsar make:controller OrderController Orders Client
vendor/bin/pulsar make:resource OrderResource Orders Client

Context: normalized output — Each Command reports its exact success text and created path.

txt
Domain created successfully
Location: app/Pulsar/Domain/Orders/.gitkeep

DTO created successfully
Location: app/Pulsar/Domain/Orders/DTOs/CreateOrderData.php

Model created successfully
Location: app/Pulsar/Domain/Orders/Models/Order.php

Action created successfully
Location: app/Pulsar/Domain/Orders/Actions/CreateOrder.php

UseCase created successfully!
Location: app/Pulsar/Services/Client/Modules/Orders/UseCases/CreateOrder.php

Request created successfully!
Location: app/Pulsar/Services/Client/Modules/Orders/Requests/StoreOrderRequest.php

Controller created successfully
Location: app/Pulsar/Services/Client/Modules/Orders/Controllers/OrderController.php

Resource created successfully
Location: app/Pulsar/Services/Client/Modules/Orders/Resources/OrderResource.php

The generated DTO constructor and from() method contain value placeholders. The Model has no allowed attributes yet. Action and UseCase execute() methods have behavior placeholders. The Request initially denies authorization and has empty rules; the Controller has no delivery method; the Resource has no fields. Replace each placeholder at the checkpoint that owns its responsibility.

The checked generated state is byte-identical on the prepared applications: skeleton v12.12.2 with Laravel v12.65.0, and skeleton v13.8.0 with Laravel v13.24.0. make:domain also creates the empty .gitkeep; the existing Client route file remains its original generated baseline.

Checkpoint 2: Build the persisted workflow

First ask Laravel to create the migration. Laravel owns this file and its timestamped path.

Context: registered application root — Create the stock Laravel migration.

bash
php artisan make:migration create_orders_table
php artisan migrate:fresh
php artisan route:list --name=client.orders.store
php artisan make:test PlaceOrderTest
php artisan test tests/Feature/PlaceOrderTest.php

Edit the delivery-neutral values, persisted Model, one atomic Action, one transactional UseCase, and the stock migration. The Domain is independent of delivery concerns, not independent of Laravel.

Generatedapp/Pulsar/Domain/Orders — Generated starting points, edited to carry values and perform one insert.

php
<?php

namespace App\Pulsar\Domain\Orders\DTOs;

readonly class CreateOrderData
{
    public function __construct(
        public string $customerName,
        public int $totalCents,
    ) {}

    /**
     * @param array{customer_name: string, total_cents: int} $data
     */
    public static function from(array $data): self
    {
        return new self(
            customerName: $data['customer_name'],
            totalCents: $data['total_cents'],
        );
    }
}
php
<?php

namespace App\Pulsar\Domain\Orders\Models;

use Illuminate\Database\Eloquent\Model;

class Order extends Model
{
    /**
     * @var list<string>
     */
    protected $fillable = [
        'customer_name',
        'total_cents',
    ];
}
php
<?php

namespace App\Pulsar\Domain\Orders\Actions;

use App\Pulsar\Domain\Orders\DTOs\CreateOrderData;
use App\Pulsar\Domain\Orders\Models\Order;

final class CreateOrder
{
    public function execute(CreateOrderData $data): Order
    {
        return Order::query()->create([
            'customer_name' => $data->customerName,
            'total_cents' => $data->totalCents,
        ]);
    }
}

CreateOrderData maps validated snake-case delivery fields into typed values and calls no behavior. Order allows only those two persisted attributes. The final Domain Action makes exactly one insert and owns no transaction, Event, branch, or HTTP concern.

Generatedapp/Pulsar/Services/Client/Modules/Orders/UseCases/CreateOrder.php — The edited UseCase owns the sole transaction and one Action call.

php
<?php

namespace App\Pulsar\Services\Client\Modules\Orders\UseCases;

use App\Pulsar\Domain\Orders\Actions\CreateOrder as CreateOrderAction;
use App\Pulsar\Domain\Orders\DTOs\CreateOrderData;
use App\Pulsar\Domain\Orders\Models\Order;
use Illuminate\Support\Facades\DB;

final class CreateOrder
{
    public function __construct(
        private readonly CreateOrderAction $createOrder,
    ) {}

    public function execute(CreateOrderData $data): Order
    {
        return DB::transaction(
            fn (): Order => $this->createOrder->execute($data),
        );
    }
}

The alias keeps the Domain Action distinct from the same-named Service UseCase. Runtime data enters execute(); the constructor contains its single workflow dependency.

database/migrations/<timestamp>_create_orders_table.php — Laravel-owned schema for the two-field tutorial order.

php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->string('customer_name', 120);
            $table->unsignedInteger('total_cents');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};

Apply the exact schema on a clean tutorial database.

Context: registered application root — Rebuild the database and run the orders migration.

bash
php artisan make:migration create_orders_table
php artisan migrate:fresh
php artisan route:list --name=client.orders.store
php artisan make:test PlaceOrderTest
php artisan test tests/Feature/PlaceOrderTest.php

Checkpoint 3: Add the HTTP boundary

Edit the generated Request, Controller, and Resource. authorize(): true is the explicit unauthenticated tutorial choice. The Request owns HTTP validation; it does not define broader Domain invariants.

Generatedapp/Pulsar/Services/Client/Modules/Orders — Edited delivery types validate, call one UseCase, and shape three fields.

php
<?php

namespace App\Pulsar\Services\Client\Modules\Orders\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    /**
     * @return array<string, list<string>>
     */
    public function rules(): array
    {
        return [
            'customer_name' => ['required', 'string', 'max:120'],
            'total_cents' => ['required', 'integer', 'min:1'],
        ];
    }

    /**
     * @return array<string, string>
     */
    public function messages(): array
    {
        return [];
    }
}
php
<?php

namespace App\Pulsar\Services\Client\Modules\Orders\Controllers;

use App\Pulsar\Domain\Orders\DTOs\CreateOrderData;
use App\Pulsar\Services\Client\Modules\Orders\Requests\StoreOrderRequest;
use App\Pulsar\Services\Client\Modules\Orders\Resources\OrderResource;
use App\Pulsar\Services\Client\Modules\Orders\UseCases\CreateOrder;
use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Controller;
use Symfony\Component\HttpFoundation\Response;

final class OrderController extends Controller
{
    public function __construct(
        private readonly CreateOrder $createOrder,
    ) {}

    public function store(StoreOrderRequest $request): JsonResponse
    {
        $order = $this->createOrder->execute(
            CreateOrderData::from($request->validated()),
        );

        return (new OrderResource($order))
            ->response()
            ->setStatusCode(Response::HTTP_CREATED);
    }
}
php
<?php

namespace App\Pulsar\Services\Client\Modules\Orders\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class OrderResource extends JsonResource
{
    /**
     * @return array{id: int, customer_name: string, total_cents: int}
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'customer_name' => $this->customer_name,
            'total_cents' => $this->total_cents,
        ];
    }
}

The Controller builds the DTO, calls its one UseCase once, and owns top-level Resource assembly and 201 Created. It calls no Action, Query, Operation, Model mutation, or transaction. The Resource shapes only id, customer_name, and total_cents.

Add the handwritten endpoint to the existing Pulsar-generated Service route file. The generated RouteServiceProvider supplies /api/client, the client. name prefix, and api middleware; this route itself remains application code.

app/Pulsar/Services/Client/Routes/api.php — Add the application-owned order route.

php
<?php

use App\Pulsar\Services\Client\Modules\Orders\Controllers\OrderController;
use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| Client Service Routes
|--------------------------------------------------------------------------
|
| Prefix: /api/client
| Named Routes: client.*
| Middleware: api
|
*/

Route::post('/orders', [OrderController::class, 'store'])
    ->name('orders.store');

Context: registered application root — Confirm the composed named route.

bash
php artisan make:migration create_orders_table
php artisan migrate:fresh
php artisan route:list --name=client.orders.store
php artisan make:test PlaceOrderTest
php artisan test tests/Feature/PlaceOrderTest.php

Context: normalized route list — Laravel exposes one matching POST route.

txt
POST  api/client/orders  client.orders.store  App\Pulsar\Services\Client\Modules\Orders\Controllers\OrderController@store

The valid fresh-database Request returns the normalized id: 1 evidence below. That value is not a global identifier promise.

Context: valid JSON Request — Send the two validated fields.

json
{"customer_name":"Ada Lovelace","total_cents":2599}

Context: HTTP 201 JSON response — The Resource wraps the persisted order.

json
{"data":{"id":1,"customer_name":"Ada Lovelace","total_cents":2599}}

An empty JSON object returns HTTP 422, reports both validation keys, and inserts no order.

Context: HTTP 422 JSON response — Normalized invalid-input evidence.

json
{"message":"The customer name field is required. (and 1 more error)","errors":{"customer_name":["The customer name field is required."],"total_cents":["The total cents field is required."]}}

Checkpoint 4: Prove the feature

Create Laravel's stock feature-test file, then replace its example with the checked public-boundary tests.

Context: registered application root — Create and run the stock Laravel feature-test file.

bash
php artisan make:migration create_orders_table
php artisan migrate:fresh
php artisan route:list --name=client.orders.store
php artisan make:test PlaceOrderTest
php artisan test tests/Feature/PlaceOrderTest.php

tests/Feature/PlaceOrderTest.php — Prove the successful response and persisted row.

php
<?php

namespace Tests\Feature;

use App\Pulsar\Domain\Orders\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PlaceOrderTest extends TestCase
{
    use RefreshDatabase;

    public function test_a_client_can_place_an_order(): void
    {
        $response = $this->postJson('/api/client/orders', [
            'customer_name' => 'Ada Lovelace',
            'total_cents' => 2599,
        ]);

        $response
            ->assertCreated()
            ->assertJsonPath('data.id', 1)
            ->assertJsonPath('data.customer_name', 'Ada Lovelace')
            ->assertJsonPath('data.total_cents', 2599)
            ->assertJsonCount(3, 'data');

        $this->assertDatabaseHas(Order::class, [
            'customer_name' => 'Ada Lovelace',
            'total_cents' => 2599,
        ]);
        $this->assertDatabaseCount(Order::class, 1);
    }

    public function test_an_invalid_order_is_rejected_without_persistence(): void
    {
        $this->postJson('/api/client/orders', [])
            ->assertUnprocessable()
            ->assertJsonValidationErrors(['customer_name', 'total_cents'])
            ->assertJsonCount(2, 'errors');

        $this->assertDatabaseCount(Order::class, 0);
    }
}

tests/Feature/PlaceOrderTest.php — Prove validation failure and zero persistence.

php
<?php

namespace Tests\Feature;

use App\Pulsar\Domain\Orders\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class PlaceOrderTest extends TestCase
{
    use RefreshDatabase;

    public function test_a_client_can_place_an_order(): void
    {
        $response = $this->postJson('/api/client/orders', [
            'customer_name' => 'Ada Lovelace',
            'total_cents' => 2599,
        ]);

        $response
            ->assertCreated()
            ->assertJsonPath('data.id', 1)
            ->assertJsonPath('data.customer_name', 'Ada Lovelace')
            ->assertJsonPath('data.total_cents', 2599)
            ->assertJsonCount(3, 'data');

        $this->assertDatabaseHas(Order::class, [
            'customer_name' => 'Ada Lovelace',
            'total_cents' => 2599,
        ]);
        $this->assertDatabaseCount(Order::class, 1);
    }

    public function test_an_invalid_order_is_rejected_without_persistence(): void
    {
        $this->postJson('/api/client/orders', [])
            ->assertUnprocessable()
            ->assertJsonValidationErrors(['customer_name', 'total_cents'])
            ->assertJsonCount(2, 'errors');

        $this->assertDatabaseCount(Order::class, 0);
    }
}

Context: normalized test summary — Execution evidence on each prepared Laravel major.

txt
Tests: 2 passed (13 assertions)

Both prepared applications passed two tests and 13 assertions. Treat those counts as drift evidence for this package/framework matrix, not timeless Laravel output.

Next step

Tour the project you built to follow the Request through every file and see why the UseCase, rather than the Controller or Action, owns the transaction.