Status 2026-07-13 — implemented (Phase 4, on feature/130-report-builder — PR: #608)
Implemented as planned with two as-built deviations, both simplifications:
- No new columns were needed. The schema already had
invoices.template, quotes.template, and companies.invoice_template / companies.quote_template — the implementation reuses them instead of adding pdf_template (zero migrations; models use $guarded = [], so no $fillable change either).
- Resolution lives in one place:
Modules/Core/Services/PdfGenerationService::resolveTemplate() handles both document types (document slug → company default column → system/<type>/default; company clones shadow same-slug system templates), rather than per-service duplicates in InvoiceService/QuoteService.
Delivered: "PDF Template" select on the invoice and quote forms (options from ReportTemplateStorage::optionsForType(), nullable with "Company default" placeholder); resolution-chain tests in PdfGenerationServiceTest (document slug wins, company default fallback, unknown/deleted slug falls through, system default floor) and picker tests in InvoiceTemplateSelectionTest (options per type, clone shadowing, persistence, clearing).
Planning Note — v2 Implementation (revised 2026-07-04 for the Report Builder)
Story
As a company admin, I want to choose a specific PDF template when creating or editing an invoice or quote, so that I can use different layouts for different clients or purposes (e.g. a formal template for corporate clients, a minimal template for freelance work) without changing the global setting each time.
v2 Context — deliver, the user clones
Templates are Report Builder templates (#130, epic #506): we ship system templates (system/invoice/default, system/quote/default) as files under storage/app/report_templates/, and users clone + customize them in the builder. There is no templates table — the dropdown lists templates by disk scan via ReportTemplateStorage (#528). The selected template is stored as a slug string on the document. Phase 4 of the roadmap: depends on #130 Phase 3 (ReportRenderer) and PR #575 (domPDF driver).
Acceptance Criteria (checked per as-built implementation — see status above)
Implementation (as built)
Modules/Core/Services/PdfGenerationService.php: resolveTemplate(Invoice|Quote $document) — document slug → company default column → system default; company scope checked before system so clones shadow system templates
Modules/Core/Services/ReportTemplateStorage.php: optionsForType(ReportTemplateType $type) — slug ⇒ name options, clones shadowing system entries
Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php + Modules/Quotes/.../Schemas/QuoteForm.php: Select::make('template') with "Company default" placeholder
- Tests:
Modules/Core/Tests/Feature/PdfGenerationServiceTest.php (resolution chain), Modules/Core/Tests/Feature/InvoiceTemplateSelectionTest.php (picker + persistence)
Original planned implementation & test scenarios (2026-07-04 — superseded by as-built notes above)
Implementation
- Module:
Modules/Invoices/ and Modules/Quotes/
- New migration:
add_pdf_template_to_invoices_table — adds pdf_template varchar(100) nullable
- New migration:
add_pdf_template_to_quotes_table — adds pdf_template varchar(100) nullable
Modules/Invoices/Models/Invoice.php + Modules/Quotes/Models/Quote.php: add 'pdf_template' to $fillable
Modules/Invoices/Services/InvoiceService.php: resolveTemplate(Invoice $invoice): string — $invoice->pdf_template if it exists on disk, else company default setting, else system/invoice/default; used by PDF generation
Modules/Quotes/Services/QuoteService.php: same resolveTemplate() pattern
- Options source:
ReportTemplateStorage::list($companyId, type: invoice|quote) mapped to ['slug' => 'Name'] (replaces the earlier blade-file glob() idea)
Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceSchema.php: Select::make('pdf_template')->options(...)->nullable()->placeholder('Use company default')
- Same for
Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteSchema.php; admin panel schemas: same additions
Test Scenarios (PHPUnit)
// Extends AbstractCompanyPanelTestCase
#[Test]
public function it_saves_custom_pdf_template_on_invoice(): void
{
$invoice = Invoice::factory()->for($this->company)->create();
app(InvoiceService::class)->updateInvoice($invoice, ['pdf_template' => 'my-clone']);
$this->assertDatabaseHas('invoices', ['id' => $invoice->id, 'pdf_template' => 'my-clone']);
}
#[Test]
public function it_resolves_per_invoice_template_when_set(): void
{
// arrange: clone exists on the report_templates disk for this company
$invoice = Invoice::factory()->for($this->company)->create(['pdf_template' => 'my-clone']);
$this->assertEquals('my-clone', app(InvoiceService::class)->resolveTemplate($invoice));
}
#[Test]
public function it_falls_back_to_company_default_then_system_default(): void
{
$invoice = Invoice::factory()->for($this->company)->create(['pdf_template' => null]);
// no company default set → system default
$this->assertEquals('system/invoice/default', app(InvoiceService::class)->resolveTemplate($invoice));
}
#[Test]
public function it_falls_back_when_selected_template_no_longer_exists(): void
{
$invoice = Invoice::factory()->for($this->company)->create(['pdf_template' => 'deleted-clone']);
$this->assertEquals('system/invoice/default', app(InvoiceService::class)->resolveTemplate($invoice));
}
#[Test]
public function it_renders_pdf_template_field_on_invoice_edit_form(): void
{
$invoice = Invoice::factory()->for($this->company)->create(['pdf_template' => 'my-clone']);
Livewire::actingAs($this->user)
->test(EditInvoice::class, [
'record' => $invoice->getKey(),
'tenant' => Str::lower($this->company->search_code),
])
->assertFormFieldHasValue('pdf_template', 'my-clone');
}
Equivalent QuoteService scenarios apply (per-quote override, fallback chain).
Status 2026-07-13 — implemented (Phase 4, on
feature/130-report-builder— PR: #608)Implemented as planned with two as-built deviations, both simplifications:
invoices.template,quotes.template, andcompanies.invoice_template/companies.quote_template— the implementation reuses them instead of addingpdf_template(zero migrations; models use$guarded = [], so no$fillablechange either).Modules/Core/Services/PdfGenerationService::resolveTemplate()handles both document types (document slug → company default column →system/<type>/default; company clones shadow same-slug system templates), rather than per-service duplicates inInvoiceService/QuoteService.Delivered: "PDF Template" select on the invoice and quote forms (options from
ReportTemplateStorage::optionsForType(), nullable with "Company default" placeholder); resolution-chain tests inPdfGenerationServiceTest(document slug wins, company default fallback, unknown/deleted slug falls through, system default floor) and picker tests inInvoiceTemplateSelectionTest(options per type, clone shadowing, persistence, clearing).Planning Note — v2 Implementation (revised 2026-07-04 for the Report Builder)
Story
As a company admin, I want to choose a specific PDF template when creating or editing an invoice or quote, so that I can use different layouts for different clients or purposes (e.g. a formal template for corporate clients, a minimal template for freelance work) without changing the global setting each time.
v2 Context — deliver, the user clones
Templates are Report Builder templates (#130, epic #506): we ship system templates (
system/invoice/default,system/quote/default) as files understorage/app/report_templates/, and users clone + customize them in the builder. There is no templates table — the dropdown lists templates by disk scan viaReportTemplateStorage(#528). The selected template is stored as a slug string on the document. Phase 4 of the roadmap: depends on #130 Phase 3 (ReportRenderer) and PR #575 (domPDF driver).Acceptance Criteria (checked per as-built implementation — see status above)
invoices.templatecolumn; no newpdf_templatecolumn was needed).quotes.templatecolumn).ReportTemplateStorage— system templates + the current company's clones, invoice-type templates for invoices, quote-type for quotes (as-built:optionsForType()).companies.invoice_template/quote_templatecolumns) →system/<type>/default.InvoiceandQuotemodels mass-assign the column (as-built:$guarded = [], nothing to add).Implementation (as built)
Modules/Core/Services/PdfGenerationService.php:resolveTemplate(Invoice|Quote $document)— document slug → company default column → system default; company scope checked before system so clones shadow system templatesModules/Core/Services/ReportTemplateStorage.php:optionsForType(ReportTemplateType $type)— slug ⇒ name options, clones shadowing system entriesModules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceForm.php+Modules/Quotes/.../Schemas/QuoteForm.php:Select::make('template')with "Company default" placeholderModules/Core/Tests/Feature/PdfGenerationServiceTest.php(resolution chain),Modules/Core/Tests/Feature/InvoiceTemplateSelectionTest.php(picker + persistence)Original planned implementation & test scenarios (2026-07-04 — superseded by as-built notes above)
Implementation
Modules/Invoices/andModules/Quotes/add_pdf_template_to_invoices_table— addspdf_templatevarchar(100) nullableadd_pdf_template_to_quotes_table— addspdf_templatevarchar(100) nullableModules/Invoices/Models/Invoice.php+Modules/Quotes/Models/Quote.php: add'pdf_template'to$fillableModules/Invoices/Services/InvoiceService.php:resolveTemplate(Invoice $invoice): string—$invoice->pdf_templateif it exists on disk, else company default setting, elsesystem/invoice/default; used by PDF generationModules/Quotes/Services/QuoteService.php: sameresolveTemplate()patternReportTemplateStorage::list($companyId, type: invoice|quote)mapped to['slug' => 'Name'](replaces the earlier blade-fileglob()idea)Modules/Invoices/Filament/Company/Resources/Invoices/Schemas/InvoiceSchema.php:Select::make('pdf_template')->options(...)->nullable()->placeholder('Use company default')Modules/Quotes/Filament/Company/Resources/Quotes/Schemas/QuoteSchema.php; admin panel schemas: same additionsTest Scenarios (PHPUnit)
Equivalent
QuoteServicescenarios apply (per-quote override, fallback chain).