From 773670c18febb24a95b2e17415aa545748a60164 Mon Sep 17 00:00:00 2001
From: Darko Gjorgjijoski <5760249+gdarko@users.noreply.github.com>
Date: Sat, 1 Aug 2026 14:37:22 +0200
Subject: [PATCH] feat(pdf): archival PDF/A output and document properties
(#732)
Generated files carried no document properties at all, so an archive of them
showed a column of blank titles and no author. Title, Subject, Author and
Creator are now written from the document number and company, on both drivers:
dompdf via addInfo(), Gotenberg via metadata().
dompdf needed more than the API call. It reads Title from the
element
during render(), which happens after addInfo(), so metadata set through the API
alone was silently overwritten by whatever the template put there and the two
drivers disagreed about what the file was called. The title is written into the
markup as well, escaped.
Also adds an archival format setting for Gotenberg: off, PDF/A-1b, -2b or -3b.
PDF/A-3 is what the EU e-invoicing formats expect. Verified against a stock
gotenberg:8 -- LibreOffice inside the image does the conversion and the output
carries the right pdfaid:part in its XMP -- so no extra components are needed.
A fixed list rather than free text, because the SDK forwards whatever it is
given and an unsupported value would surface only as an HTTP error from the
service at render time. Empty is a real choice meaning an ordinary PDF, so it
overrides an env default rather than falling through it.
Gotenberg only: dompdf cannot produce PDF/A.
Claude-Session: https://claude.ai/code/session_01QmECndmNZwzN65Zz9P87dF
---
app/Facades/Pdf.php | 2 +-
.../Settings/PDFConfigurationController.php | 4 +-
app/Http/Requests/PDFConfigurationRequest.php | 7 ++
app/Providers/AppConfigProvider.php | 14 ++-
app/Services/Document/EstimateService.php | 7 +-
app/Services/Document/InvoiceService.php | 7 +-
app/Services/Document/PaymentService.php | 7 +-
app/Support/Pdf/DompdfDriver.php | 44 ++++++++-
app/Support/Pdf/GotenbergPdfDriver.php | 19 +++-
app/Support/Pdf/PdfDriver.php | 8 +-
app/Support/Pdf/PdfMetadata.php | 33 +++++++
app/Support/Pdf/PdfService.php | 4 +-
config/pdf.php | 8 ++
lang/en.json | 5 +-
resources/scripts/api/services/pdf.service.ts | 2 +
.../settings/AdminPdfGotenbergDriver.vue | 29 ++++++
tests/Feature/Admin/AdminSettingsTest.php | 40 +++++++++
tests/Unit/PdfMetadataTest.php | 89 +++++++++++++++++++
18 files changed, 312 insertions(+), 17 deletions(-)
create mode 100644 app/Support/Pdf/PdfMetadata.php
create mode 100644 tests/Unit/PdfMetadataTest.php
diff --git a/app/Facades/Pdf.php b/app/Facades/Pdf.php
index bb96850f..46091f76 100644
--- a/app/Facades/Pdf.php
+++ b/app/Facades/Pdf.php
@@ -5,7 +5,7 @@ namespace App\Facades;
use Illuminate\Support\Facades\Facade;
/**
- * @method static \App\Support\Pdf\ResponseStream loadView(string $template)
+ * @method static \App\Support\Pdf\ResponseStream loadView(string $template, array $metadata = [])
*/
class Pdf extends Facade
{
diff --git a/app/Http/Controllers/Admin/Settings/PDFConfigurationController.php b/app/Http/Controllers/Admin/Settings/PDFConfigurationController.php
index cfe96c59..acc151d0 100644
--- a/app/Http/Controllers/Admin/Settings/PDFConfigurationController.php
+++ b/app/Http/Controllers/Admin/Settings/PDFConfigurationController.php
@@ -60,7 +60,7 @@ class PDFConfigurationController extends Controller
$this->authorize('manage pdf config');
$pdfSettings = Setting::getSettings(array_merge(
- ['pdf_driver', 'gotenberg_host'],
+ ['pdf_driver', 'gotenberg_host', 'gotenberg_pdfa'],
self::PAGE_SETTINGS,
self::PAGE_BOOLEANS,
));
@@ -68,6 +68,7 @@ class PDFConfigurationController extends Controller
$config = [
'pdf_driver' => $pdfSettings['pdf_driver'] ?? config('pdf.driver'),
'gotenberg_host' => $pdfSettings['gotenberg_host'] ?? config('pdf.connections.gotenberg.host'),
+ 'gotenberg_pdfa' => $pdfSettings['gotenberg_pdfa'] ?? config('pdf.connections.gotenberg.pdfa') ?? '',
];
// Page geometry applies to whichever driver is selected, so it is always
@@ -134,6 +135,7 @@ class PDFConfigurationController extends Controller
if ($driver === 'gotenberg') {
$settings['gotenberg_host'] = $request->get('gotenberg_host');
+ $settings['gotenberg_pdfa'] = $request->get('gotenberg_pdfa') ?? '';
}
return $settings;
diff --git a/app/Http/Requests/PDFConfigurationRequest.php b/app/Http/Requests/PDFConfigurationRequest.php
index ba7f0152..98dd4926 100644
--- a/app/Http/Requests/PDFConfigurationRequest.php
+++ b/app/Http/Requests/PDFConfigurationRequest.php
@@ -10,6 +10,9 @@ use Illuminate\Validation\Rule;
class PDFConfigurationRequest extends FormRequest
{
+ /** Formats the Gotenberg image can actually produce, verified against gotenberg:8. */
+ public const PDFA_FORMATS = ['PDF/A-1b', 'PDF/A-2b', 'PDF/A-3b'];
+
/**
* Determine if the user is authorized to make this request.
*/
@@ -67,6 +70,10 @@ class PDFConfigurationRequest extends FormRequest
'url',
Rule::when(! $isDeclaredHost, [new PublicHttpUrl]),
],
+ // A fixed list rather than a free string: the SDK forwards whatever
+ // it is given, so an unsupported value would only fail later as an
+ // HTTP error from the Gotenberg service.
+ 'gotenberg_pdfa' => ['nullable', Rule::in(self::PDFA_FORMATS)],
];
}
}
diff --git a/app/Providers/AppConfigProvider.php b/app/Providers/AppConfigProvider.php
index 2ad0ce5a..a04576c9 100644
--- a/app/Providers/AppConfigProvider.php
+++ b/app/Providers/AppConfigProvider.php
@@ -57,15 +57,23 @@ class AppConfigProvider extends ServiceProvider
];
$pdfSettings = Setting::getSettings(array_merge(
- ['pdf_driver', 'gotenberg_host', 'pdf_page_numbers'],
+ ['pdf_driver', 'gotenberg_host', 'gotenberg_pdfa', 'pdf_page_numbers'],
array_keys($pageSettings),
));
if (! empty($pdfSettings['pdf_driver'])) {
Config::set('pdf.driver', $pdfSettings['pdf_driver']);
- if ($pdfSettings['pdf_driver'] === 'gotenberg' && ! empty($pdfSettings['gotenberg_host'])) {
- Config::set('pdf.connections.gotenberg.host', $pdfSettings['gotenberg_host']);
+ if ($pdfSettings['pdf_driver'] === 'gotenberg') {
+ if (! empty($pdfSettings['gotenberg_host'])) {
+ Config::set('pdf.connections.gotenberg.host', $pdfSettings['gotenberg_host']);
+ }
+
+ // Empty is a real choice here -- it means an ordinary PDF --
+ // so an explicitly stored blank must override an env default.
+ if (isset($pdfSettings['gotenberg_pdfa'])) {
+ Config::set('pdf.connections.gotenberg.pdfa', $pdfSettings['gotenberg_pdfa'] ?: null);
+ }
}
}
diff --git a/app/Services/Document/EstimateService.php b/app/Services/Document/EstimateService.php
index f7fb5d7d..2a9cc9d9 100644
--- a/app/Services/Document/EstimateService.php
+++ b/app/Services/Document/EstimateService.php
@@ -13,6 +13,7 @@ use App\Models\Estimate;
use App\Models\ExchangeRateLog;
use App\Models\Invoice;
use App\Services\Mail\CompanyMailConfigService;
+use App\Support\Pdf\PdfMetadata;
use App\Support\Pdf\PdfTemplateUtils;
use Carbon\Carbon;
use Illuminate\Http\Request;
@@ -201,7 +202,11 @@ class EstimateService
return view($templatePath);
}
- return Pdf::loadView($templatePath);
+ return Pdf::loadView($templatePath, PdfMetadata::forDocument(
+ __('pdf_estimate_label'),
+ $estimate->estimate_number,
+ $company,
+ ));
}
public function clone(Estimate $estimate): Estimate
diff --git a/app/Services/Document/InvoiceService.php b/app/Services/Document/InvoiceService.php
index d5313037..a302d3f9 100644
--- a/app/Services/Document/InvoiceService.php
+++ b/app/Services/Document/InvoiceService.php
@@ -13,6 +13,7 @@ use App\Models\Estimate;
use App\Models\ExchangeRateLog;
use App\Models\Invoice;
use App\Services\Mail\CompanyMailConfigService;
+use App\Support\Pdf\PdfMetadata;
use App\Support\Pdf\PdfTemplateUtils;
use Carbon\Carbon;
use Illuminate\Http\Request;
@@ -265,7 +266,11 @@ class InvoiceService
return view($templatePath);
}
- return Pdf::loadView($templatePath);
+ return Pdf::loadView($templatePath, PdfMetadata::forDocument(
+ __('pdf_invoice_label'),
+ $invoice->invoice_number,
+ $company,
+ ));
}
public function clone(Invoice $invoice): Invoice
diff --git a/app/Services/Document/PaymentService.php b/app/Services/Document/PaymentService.php
index 5cfe2799..543f6be2 100644
--- a/app/Services/Document/PaymentService.php
+++ b/app/Services/Document/PaymentService.php
@@ -11,6 +11,7 @@ use App\Models\ExchangeRateLog;
use App\Models\Invoice;
use App\Models\Payment;
use App\Services\Mail\CompanyMailConfigService;
+use App\Support\Pdf\PdfMetadata;
use App\Support\Pdf\PdfTemplateUtils;
use Carbon\Carbon;
use Illuminate\Http\Request;
@@ -188,7 +189,11 @@ class PaymentService
return view($templatePath);
}
- return Pdf::loadView($templatePath);
+ return Pdf::loadView($templatePath, PdfMetadata::forDocument(
+ __('pdf_payment_label'),
+ $payment->payment_number,
+ $company,
+ ));
}
public function generateFromTransaction($transaction): Payment
diff --git a/app/Support/Pdf/DompdfDriver.php b/app/Support/Pdf/DompdfDriver.php
index 6bc176bd..25f8deef 100644
--- a/app/Support/Pdf/DompdfDriver.php
+++ b/app/Support/Pdf/DompdfDriver.php
@@ -15,13 +15,20 @@ use Illuminate\Support\Facades\App;
*/
class DompdfDriver implements PdfDriver
{
- public function loadView(string $template): ResponseStream
+ public function loadView(string $template, array $metadata = []): ResponseStream
{
$page = PdfPageSetup::fromConfig();
+ $html = $this->withPageMargins(view($template)->render(), $page);
+ $html = $this->withDocumentTitle($html, $metadata['Title'] ?? null);
+
$pdf = $this->wrapper();
$pdf->setPaper($page->dompdfPaper(), $page->orientation);
- $pdf->loadHTML($this->withPageMargins(view($template)->render(), $page));
+ $pdf->loadHTML($html);
+
+ if ($metadata !== []) {
+ $pdf->addInfo($metadata);
+ }
return new DompdfResponse($pdf);
}
@@ -44,6 +51,39 @@ class DompdfDriver implements PdfDriver
return $count ? $injected : $style.$html;
}
+ /**
+ * dompdf reads the document Title from the element during render(),
+ * which happens after any addInfo() call, so metadata set through the API is
+ * silently overwritten by whatever the template happened to put there. The
+ * only way to make the two drivers agree on the title is to write it into
+ * the markup.
+ */
+ private function withDocumentTitle(string $html, ?string $title): string
+ {
+ if ($title === null || $title === '') {
+ return $html;
+ }
+
+ $escaped = htmlspecialchars($title, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+
+ $replaced = preg_replace(
+ '#]*>.*?#is',
+ "{$escaped}",
+ $html,
+ 1,
+ $count
+ );
+
+ if ($count) {
+ return $replaced;
+ }
+
+ // No to replace, so add one. dompdf only looks inside .
+ $injected = preg_replace('/(]*>)/i', "$1{$escaped}", $html, 1, $count);
+
+ return $count ? $injected : $html;
+ }
+
protected function wrapper(): PDF
{
return App::make('dompdf.wrapper');
diff --git a/app/Support/Pdf/GotenbergPdfDriver.php b/app/Support/Pdf/GotenbergPdfDriver.php
index ab080d90..90bb91d3 100644
--- a/app/Support/Pdf/GotenbergPdfDriver.php
+++ b/app/Support/Pdf/GotenbergPdfDriver.php
@@ -11,9 +11,9 @@ use Psr\Http\Message\RequestInterface;
class GotenbergPdfDriver implements PdfDriver
{
- public function loadView(string $template): ResponseStream
+ public function loadView(string $template, array $metadata = []): ResponseStream
{
- return new GotenbergPdfResponse(Gotenberg::send($this->buildRequest($template)));
+ return new GotenbergPdfResponse(Gotenberg::send($this->buildRequest($template, $metadata)));
}
/**
@@ -23,7 +23,7 @@ class GotenbergPdfDriver implements PdfDriver
* below this line used to be inlined into loadView(), which meant the only
* way to check that an option was set was to run a Gotenberg service.
*/
- public function buildRequest(string $template): RequestInterface
+ public function buildRequest(string $template, array $metadata = []): RequestInterface
{
$page = PdfPageSetup::fromConfig();
[$width, $height] = $page->gotenbergPaper();
@@ -66,6 +66,19 @@ class GotenbergPdfDriver implements PdfDriver
$chromium->landscape();
}
+ // Archival conformance, converted by LibreOffice inside the Gotenberg
+ // image. PDF/A-3 is what the EU e-invoicing formats ask for. The value is
+ // passed through unvalidated by the SDK, so an unsupported one surfaces
+ // as an HTTP error from the service; the setting is a fixed list for
+ // that reason.
+ if ($pdfa = config('pdf.connections.gotenberg.pdfa')) {
+ $chromium->pdfa($pdfa);
+ }
+
+ if ($metadata !== []) {
+ $chromium->metadata($metadata);
+ }
+
// Must be attached before html(), which is terminal: it returns the built
// request rather than the builder.
if ($header = $this->companion($template, '_header')) {
diff --git a/app/Support/Pdf/PdfDriver.php b/app/Support/Pdf/PdfDriver.php
index 3dbe06c7..e999b353 100644
--- a/app/Support/Pdf/PdfDriver.php
+++ b/app/Support/Pdf/PdfDriver.php
@@ -4,5 +4,11 @@ namespace App\Support\Pdf;
interface PdfDriver
{
- public function loadView(string $template): ResponseStream;
+ /**
+ * @param array $metadata Document properties written into
+ * the file: Title, Author, Subject,
+ * Keywords, Creator. Both drivers
+ * accept the same key names.
+ */
+ public function loadView(string $template, array $metadata = []): ResponseStream;
}
diff --git a/app/Support/Pdf/PdfMetadata.php b/app/Support/Pdf/PdfMetadata.php
new file mode 100644
index 00000000..a2aaae1c
--- /dev/null
+++ b/app/Support/Pdf/PdfMetadata.php
@@ -0,0 +1,33 @@
+
+ */
+ public static function forDocument(string $subject, ?string $number, ?Company $company): array
+ {
+ $title = trim($subject.' '.($number ?? ''));
+
+ return array_filter([
+ 'Title' => $title,
+ 'Subject' => $subject,
+ 'Author' => $company?->name,
+ 'Creator' => config('app.name', 'InvoiceShelf'),
+ ], fn ($value) => is_string($value) && $value !== '');
+ }
+}
diff --git a/app/Support/Pdf/PdfService.php b/app/Support/Pdf/PdfService.php
index 6548ab43..fc4baf57 100644
--- a/app/Support/Pdf/PdfService.php
+++ b/app/Support/Pdf/PdfService.php
@@ -4,10 +4,10 @@ namespace App\Support\Pdf;
class PdfService
{
- public static function loadView(string $template): ResponseStream
+ public static function loadView(string $template, array $metadata = []): ResponseStream
{
$driver = config('pdf.driver');
- return PdfDriverFactory::create($driver)->loadView($template);
+ return PdfDriverFactory::create($driver)->loadView($template, $metadata);
}
}
diff --git a/config/pdf.php b/config/pdf.php
index 23bdd038..31308154 100644
--- a/config/pdf.php
+++ b/config/pdf.php
@@ -64,6 +64,14 @@ return [
'gotenberg' => [
'host' => env('GOTENBERG_HOST', 'http://pdf:3000'),
+ /*
+ * Archival conformance, converted by LibreOffice inside the Gotenberg
+ * image. Empty means an ordinary PDF. PDF/A-3 is what the EU
+ * e-invoicing formats ask for. Gotenberg only: dompdf cannot produce
+ * PDF/A.
+ */
+ 'pdfa' => env('GOTENBERG_PDFA'),
+
/*
* Gotenberg usually runs as a sidecar on a private network, which the
* SSRF guard rejects. Name that one host here to exempt it — e.g.
diff --git a/lang/en.json b/lang/en.json
index d7cf8a84..07e390fd 100644
--- a/lang/en.json
+++ b/lang/en.json
@@ -1068,7 +1068,10 @@
"margin_left": "Left Margin",
"length_hint": "A number and a unit, e.g. \"210mm\". Accepts: pt, px, pc, mm, cm, in.",
"page_numbers": "Page Numbers",
- "page_numbers_hint": "Repeat the page number at the foot of every page. Needs a bottom margin to sit in."
+ "page_numbers_hint": "Repeat the page number at the foot of every page. Needs a bottom margin to sit in.",
+ "pdfa": "Archival Format (PDF/A)",
+ "pdfa_hint": "Long-term archival conformance. PDF/A-3 is what EU e-invoicing formats expect. Leave off for an ordinary PDF.",
+ "pdfa_off": "Off"
},
"company_info": {
"company_info": "Company info",
diff --git a/resources/scripts/api/services/pdf.service.ts b/resources/scripts/api/services/pdf.service.ts
index da231b8b..9394c27c 100644
--- a/resources/scripts/api/services/pdf.service.ts
+++ b/resources/scripts/api/services/pdf.service.ts
@@ -30,6 +30,8 @@ export interface DomPdfConfig extends PdfPageSetup {
export interface GotenbergConfig extends PdfPageSetup {
pdf_driver: string
gotenberg_host: string
+ /** '' for an ordinary PDF, or one of the PDF/A conformance levels. */
+ gotenberg_pdfa: string
}
export type PdfConfig = DomPdfConfig | GotenbergConfig
diff --git a/resources/scripts/features/admin/components/settings/AdminPdfGotenbergDriver.vue b/resources/scripts/features/admin/components/settings/AdminPdfGotenbergDriver.vue
index 49f740a6..f96c9305 100644
--- a/resources/scripts/features/admin/components/settings/AdminPdfGotenbergDriver.vue
+++ b/resources/scripts/features/admin/components/settings/AdminPdfGotenbergDriver.vue
@@ -37,9 +37,20 @@ const { t } = useI18n()
const form = reactive({
pdf_driver: 'gotenberg',
gotenberg_host: '',
+ gotenberg_pdfa: '',
...pageSetupDefaults(),
})
+// Only what the Gotenberg image can actually produce, checked against
+// gotenberg:8. The SDK forwards the value unvalidated, so anything else would
+// fail as an HTTP error at render time.
+const pdfaFormats = computed(() => [
+ { label: t('settings.pdf.pdfa_off'), value: '' },
+ { label: 'PDF/A-1b', value: 'PDF/A-1b' },
+ { label: 'PDF/A-2b', value: 'PDF/A-2b' },
+ { label: 'PDF/A-3b', value: 'PDF/A-3b' },
+])
+
function isValidServiceUrl(value: string): boolean {
if (!helpers.req(value)) {
return true
@@ -89,6 +100,10 @@ onMounted(() => {
form.gotenberg_host = props.configData.gotenberg_host
}
+ if (typeof props.configData.gotenberg_pdfa === 'string') {
+ form.gotenberg_pdfa = props.configData.gotenberg_pdfa
+ }
+
Object.assign(form, pageSetupFrom(props.configData))
})
@@ -141,6 +156,20 @@ function saveConfig(): void {
@input="v$.gotenberg_host.$touch()"
/>
+
+
+
+
'gotenberg',
+ 'gotenberg_host' => 'https://pdf.example.com',
+ 'gotenberg_pdfa' => 'PDF/A-9z',
+ 'pdf_paper_width' => '210mm',
+ 'pdf_paper_height' => '297mm',
+ 'pdf_orientation' => 'portrait',
+ ])->assertStatus(422)->assertJsonValidationErrors('gotenberg_pdfa');
+});
+
+test('the archival format round trips, and off is a real choice', function () {
+ postJson('/api/v1/pdf/config', [
+ 'pdf_driver' => 'gotenberg',
+ 'gotenberg_host' => 'https://pdf.example.com',
+ 'gotenberg_pdfa' => 'PDF/A-3b',
+ 'pdf_paper_width' => '210mm',
+ 'pdf_paper_height' => '297mm',
+ 'pdf_orientation' => 'portrait',
+ ])->assertOk();
+
+ getJson('/api/v1/pdf/config')->assertOk()->assertJson(['gotenberg_pdfa' => 'PDF/A-3b']);
+
+ postJson('/api/v1/pdf/config', [
+ 'pdf_driver' => 'gotenberg',
+ 'gotenberg_host' => 'https://pdf.example.com',
+ 'gotenberg_pdfa' => '',
+ 'pdf_paper_width' => '210mm',
+ 'pdf_paper_height' => '297mm',
+ 'pdf_orientation' => 'portrait',
+ ])->assertOk();
+
+ getJson('/api/v1/pdf/config')->assertOk()->assertJson(['gotenberg_pdfa' => '']);
+});
diff --git a/tests/Unit/PdfMetadataTest.php b/tests/Unit/PdfMetadataTest.php
new file mode 100644
index 00000000..3c6f5f7c
--- /dev/null
+++ b/tests/Unit/PdfMetadataTest.php
@@ -0,0 +1,89 @@
+ 'ACME Corp']);
+
+ expect(PdfMetadata::forDocument('Invoice', 'INV-000042', $company))->toBe([
+ 'Title' => 'Invoice INV-000042',
+ 'Subject' => 'Invoice',
+ 'Author' => 'ACME Corp',
+ 'Creator' => config('app.name', 'InvoiceShelf'),
+ ]);
+});
+
+test('missing pieces are left out rather than written as empty strings', function () {
+ $metadata = PdfMetadata::forDocument('Invoice', null, null);
+
+ expect($metadata)->not->toHaveKey('Author')
+ ->and($metadata['Title'])->toBe('Invoice');
+});
+
+test('the gotenberg request carries the metadata', function () {
+ config(['pdf.connections.gotenberg.host' => 'http://gotenberg.example.com:3000']);
+
+ $body = (string) (new GotenbergPdfDriver)->buildRequest(
+ 'app.pdf.partials.fonts',
+ ['Title' => 'Invoice INV-000042', 'Author' => 'ACME Corp']
+ )->getBody();
+
+ expect($body)->toContain('metadata')
+ ->and($body)->toContain('Invoice INV-000042')
+ ->and($body)->toContain('ACME Corp');
+});
+
+/**
+ * dompdf reads Title from the element during render(), after any
+ * addInfo() call, so metadata set through the API alone is silently overwritten
+ * by whatever the template happened to put there -- and the two drivers end up
+ * disagreeing about what the file is called.
+ */
+test('dompdf writes the metadata title into the markup so it is not overwritten', function () {
+ $method = new ReflectionMethod(DompdfDriver::class, 'withDocumentTitle');
+
+ $result = $method->invoke(
+ new DompdfDriver,
+ 'whatever the template said',
+ 'Invoice INV-000042'
+ );
+
+ expect($result)->toContain('Invoice INV-000042')
+ ->and($result)->not->toContain('whatever the template said');
+});
+
+test('a document with no title element gets one', function () {
+ $method = new ReflectionMethod(DompdfDriver::class, 'withDocumentTitle');
+
+ $result = $method->invoke(new DompdfDriver, '', 'Invoice 42');
+
+ expect($result)->toContain('Invoice 42');
+});
+
+test('a title is escaped rather than injected as markup', function () {
+ $method = new ReflectionMethod(DompdfDriver::class, 'withDocumentTitle');
+
+ $result = $method->invoke(
+ new DompdfDriver,
+ 'x',
+ 'Invoice '
+ );
+
+ expect($result)->not->toContain('