Your First PR
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:
| File | Purpose |
|---|---|
database/migrations/2026_05_24_000000_create_customer_notes_table.php | Schema |
src/Domain/Customer/Models/Entities/CustomerNote.php | Eloquent model |
src/Domain/Customer/Models/Scopes/CustomerNoteFilter.php | scopeFilterBy trait |
src/Domain/Customer/DataTransferObjects/FilterCustomerNoteData.php | Read DTO |
src/Domain/Customer/DataTransferObjects/CreateCustomerNoteData.php | Write DTO |
src/Domain/Customer/Actions/GetCustomerNotes.php | Paginated list Action |
src/Domain/Customer/Actions/CreateCustomerNote.php | Create 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.php | Partial for the customer show page |
src/Presentation/Customer/Controllers/Notes/NoteController.php | API controller |
src/Presentation/Customer/Requests/Notes/StoreNoteRequest.php | Form Request |
src/Presentation/Customer/Resources/NoteResource.php | API Resource |
src/Presentation/Customer/Routes/api.php | Route registration |
lang/en/inputs.php + lang/ar/inputs.php | Translations (field labels) |
tests/Feature/Customer/CreateNoteTest.php + tests/Unit/Customer/CreateCustomerNoteTest.php | Tests |
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,
];
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.