Get Started

Your First PR

This chapter walks through a complete, realistic change end-to-end: adding a `Note` concept to a `Customer` domain. The goal is not the feature itself — it's to see every file the pattern touches and the order in which they are created.

Read it once before you write your first PR. The Key Concepts chapters under Guide are the canonical references for each piece; this chapter ties them together.

The task

Customers can attach freeform notes to themselves. A note has a body, an author, and a timestamp. The admin panel lists a customer's notes on their detail page; the customer-facing API can create and list a customer's own notes.

1. Plan the files

Before touching any code, list what you'll create. For an entirely new entity attached to an existing domain:

FilePurpose
database/migrations/2026_05_24_000000_create_customer_notes_table.phpSchema
src/Domain/Customer/Models/Entities/CustomerNote.phpEloquent model
src/Domain/Customer/Models/Scopes/CustomerNoteFilter.phpscopeFilterBy trait
src/Domain/Customer/DataTransferObjects/FilterCustomerNoteData.phpRead DTO
src/Domain/Customer/DataTransferObjects/CreateCustomerNoteData.phpWrite DTO
src/Domain/Customer/Actions/GetCustomerNotes.phpPaginated list Action
src/Domain/Customer/Actions/CreateCustomerNote.phpCreate Action
src/Domain/Customer/Contracts/CustomerNoteContract.php (or extend CustomerContract)Public API
src/Domain/Customer/Services/CustomerNoteService.php (or extend CustomerService)Contract implementation
src/Presentation/Admin/Views/customers/_notes.blade.phpPartial for the customer show page
src/Presentation/Customer/Controllers/Notes/NoteController.phpAPI controller
src/Presentation/Customer/Requests/Notes/StoreNoteRequest.phpForm Request
src/Presentation/Customer/Resources/NoteResource.phpAPI Resource
src/Presentation/Customer/Routes/api.phpRoute registration
lang/en/inputs.php + lang/ar/inputs.phpTranslations (field labels)
tests/Feature/Customer/CreateNoteTest.php + tests/Unit/Customer/CreateCustomerNoteTest.phpTests

That's the full list. Each chapter linked from the table is the template for the corresponding file — copy and adapt rather than inventing shapes.

2. Migration

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('customer_notes', function (Blueprint $table) {
            $table->id();
            $table->foreignIdFor(Customer::class)->constrained()->cascadeOnDelete();
            $table->foreignIdFor(User::class, 'author_id')->constrained('users');
            $table->text('body');
            $table->timestamps();
        });
    }

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

No index() calls — the two foreign keys already index themselves. See migrations.

3. Model

namespace Domain\Customer\Models\Entities;

use Domain\Customer\Models\Scopes\CustomerNoteFilter;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class CustomerNote extends Model
{
    use CustomerNoteFilter;

    protected $fillable = ['customer_id', 'author_id', 'body'];

    public function customer(): BelongsTo
    {
        return $this->belongsTo(Customer::class);
    }

    public function author(): BelongsTo
    {
        return $this->belongsTo(User::class, 'author_id');
    }
}

$fillable rather than $guarded = [] because this model accepts user input directly. See security.

4. Filter trait

namespace Domain\Customer\Models\Scopes;

use Domain\Customer\DataTransferObjects\FilterCustomerNoteData;
use Illuminate\Database\Eloquent\Builder;

trait CustomerNoteFilter
{
    public function scopeFilterBy(Builder $query, ?FilterCustomerNoteData $filters): void
    {
        $query
            ->when($filters?->customer_id, fn (Builder $q) => $q->whereByType('customer_id', $filters->customer_id))
            ->when($filters?->author_id, fn (Builder $q) => $q->whereByType('author_id', $filters->author_id))
            ->when($filters?->body, fn (Builder $q) => $q->whereLikeText('body', $filters->body));
    }
}

See Eloquent and queries for whereByType and whereLikeText.

5. DTOs

namespace Domain\Customer\DataTransferObjects;

use Domain\Core\DataTransferObjects\FilterData;
use Domain\Core\Traits\Arrayable;

final class FilterCustomerNoteData extends FilterData
{
    use Arrayable;

    public function __construct(
        public readonly ?int $id = null,
        public readonly int|array|null $customer_id = null,
        public readonly int|array|null $author_id = null,
        public readonly ?string $body = null,
    ) {}
}
namespace Domain\Customer\DataTransferObjects;

use Domain\Core\Traits\Arrayable;

final class CreateCustomerNoteData
{
    use Arrayable;

    public function __construct(
        public readonly int $customer_id,
        public readonly int $author_id,
        public readonly string $body,
    ) {}
}

Both use snake_case so the property names line up with the DB columns. See DTOs.

6. Actions

namespace Domain\Customer\Actions;

use Domain\Core\Abstracts\GetDataAbstract;
use Domain\Customer\Models\Entities\CustomerNote;
use Illuminate\Database\Eloquent\Builder;

final class GetCustomerNotes extends GetDataAbstract
{
    public function query(): Builder
    {
        return CustomerNote::filterBy($this->data);
    }
}
namespace Domain\Customer\Actions;

use Domain\Customer\DataTransferObjects\CreateCustomerNoteData;
use Domain\Customer\Models\Entities\CustomerNote;

final class CreateCustomerNote
{
    public static function handle(CreateCustomerNoteData $data): CustomerNote
    {
        return CustomerNote::create($data->toArray());
    }
}

Single-statement write, so no DB::transaction() wrapper is needed. If CreateCustomerNote later started writing related rows, the body would move inside a DB::transaction(fn () => …) block. See actions.

7. Contract + Service

CustomerNote is its own entity inside the Customer domain, so it gets its own CustomerNoteContract and CustomerNoteService — same shape as the existing CustomerContract / CustomerService pair. Methods on the new Contract use the generic create, getNotes, getNote names (the Note part is implied by the Contract itself, just like create on CustomerContract means "create a customer").

// src/Domain/Customer/Contracts/CustomerNoteContract.php
interface CustomerNoteContract
{
    public function getNotes(?FilterCustomerNoteData $data, array $withRelations = [], array $withCount = []): GetCustomerNotes;

    public function create(CreateCustomerNoteData $data): CustomerNote;
}
// src/Domain/Customer/Services/CustomerNoteService.php
class CustomerNoteService implements CustomerNoteContract
{
    public function getNotes(?FilterCustomerNoteData $data, array $withRelations = [], array $withCount = []): GetCustomerNotes
    {
        return GetCustomerNotes::for($data, $withRelations, $withCount);
    }

    public function create(CreateCustomerNoteData $data): CustomerNote
    {
        return CreateCustomerNote::handle($data);
    }
}

Bind the pair in src/Domain/Customer/Providers/CustomerServiceProvider.php alongside the existing CustomerContract binding:

public array $bindings = [
    CustomerContract::class     => CustomerService::class,
    CustomerNoteContract::class => CustomerNoteService::class,
];

See services and contracts.

8. Customer API controller

namespace Presentation\Customer\Controllers\Notes;

use App\Http\Controllers\Controller;
use Domain\Core\Helpers\ResponseBuilder;
use Domain\Customer\Contracts\CustomerNoteContract;
use Domain\Customer\DataTransferObjects\CreateCustomerNoteData;
use Domain\Customer\DataTransferObjects\FilterCustomerNoteData;
use Presentation\Customer\Requests\Notes\StoreNoteRequest;
use Presentation\Customer\Resources\NoteResource;

class NoteController extends Controller
{
    public function __construct(
        private readonly CustomerNoteContract $customerNoteContract,
    ) {}

    public function index(): ResponseBuilder
    {
        $notes = $this->customerNoteContract->getNotes(
            FilterCustomerNoteData::fromArray([
                'customer_id' => auth()->id(),
            ]),
        )->pagination();

        return res()->data(NoteResource::collection($notes));
    }

    public function store(StoreNoteRequest $request): ResponseBuilder
    {
        $note = $this->customerNoteContract->create(
            CreateCustomerNoteData::fromArray($request->validated() + [
                'customer_id' => auth()->id(),
                'author_id' => auth()->id(),
            ]),
        );

        return res(201)->data(NoteResource::make($note));
    }
}

201 on store, the envelope via res()->data(...), the customer pinned server-side rather than trusted from the client. See controllers and API responses.

9. Form Request, Resource, route

final class StoreNoteRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'body' => ['required', 'string', 'max:5000'],
        ];
    }
}
class NoteResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'body' => $this->body,
            'created_at' => $this->created_at->toISOString(),
            'author' => $this->whenLoaded('author', fn () => [
                'id' => $this->author->id,
                'name' => $this->author->name,
            ]),
        ];
    }
}
// src/Presentation/Customer/Routes/api.php
Route::middleware('auth:sanctum')->group(function () {
    Route::get('/notes', [NoteController::class, 'index'])->name('customer.notes.index');
    Route::post('/notes', [NoteController::class, 'store'])->name('customer.notes.store');
});

10. Tests

A unit test for the Action and a feature test for the endpoint cover both layers:

// tests/Unit/Customer/CreateCustomerNoteTest.php
it('creates a note for the given customer', function () {
    $customer = Customer::factory()->create();
    $author = User::factory()->create();

    $note = CreateCustomerNote::handle(new CreateCustomerNoteData(
        customer_id: $customer->id,
        author_id: $author->id,
        body: 'a quick note',
    ));

    expect($note->body)->toBe('a quick note')
        ->and($note->customer_id)->toBe($customer->id);
});
// tests/Feature/Customer/CreateNoteTest.php
it('creates a note via the API', function () {
    $customer = Customer::factory()->create();

    actingAs($customer, 'sanctum')
        ->postJson('/api/v1/notes', ['body' => 'hello'])
        ->assertStatus(201)
        ->assertJsonPath('status.code', 201)
        ->assertJsonPath('data.body', 'hello');
});

See testing.

11. Translations

// lang/en/inputs.php and lang/ar/inputs.php — add the new field labels here
return [
    // ...existing keys
    'note_body'   => 'Note',
    'note_author' => 'Author',
];

Both locales. The pre-push hook fails the push otherwise. Field labels go in lang/{locale}/inputs.php, not a per-feature file.

12. Commit and open a PR

git checkout -b feat/customercp-add-customer-notes
git add src/ database/ lang/ tests/
git commit -m "feat(customer-domain): add notes capability to Customer"
git push -u origin feat/customercp-add-customer-notes

The PR body references the Linear ticket and describes the customer-visible change. See git and commits for the full conventions.

Recap

The change touched fifteen files spread across four directories — a migration, a model, a filter trait, two DTOs, two Actions, two Contract additions, two Service additions, a controller, a Form Request, a Resource, a route, two translations, and two tests. None of those steps are optional. Skipping any of them produces a feature that works in the moment but breaks the next time someone reads or refactors the code.

The next time you're adding a similar feature, this chapter is your checklist.