Before this change, multi-step invoice operations (create, copy, credit) executed as
a series of independent EntityWriter::write() calls. Each call committed its own
transaction. If step 3 of a 5-step operation threw an exception, steps 1 and 2 were
already committed — leaving orphaned rows (e.g. an invoice with no items, or items
with no amounts).
The withTransaction() method on InvService wraps an entire operation in a single
database transaction. All writes succeed together or roll back together.
// src/Invoice/Inv/InvService.php
use Cycle\Database\DatabaseManager;
final readonly class InvService
{
public function __construct(
private InvRepository $repository,
private Translator $translator,
private CR $cR,
private GR $gR,
private UR $uR,
private DatabaseManager $dbal, // ← added
) {}
public function withTransaction(callable $fn): void
{
/** @psalm-suppress MixedArgumentTypeCoercion */
$this->dbal->database()->transaction($fn);
}
}DatabaseManager is already in the DI container — it is injected into the Cycle ORM
factory in config/common/di/cycle.php. No extra registration is needed.
The @psalm-suppress is required because Psalm types the transaction() parameter as
callable(DatabaseInterface):mixed, but our closures declare void and ignore the
injected DatabaseInterface argument. PHP silently discards the extra argument at
runtime.
$dbal->database() returns the 'default' MySQL connection configured in
config/common/params.php. ->transaction($fn) issues:
BEGIN$fn()— allEntityWriter::write()calls inside becomeSAVEPOINT+RELEASE SAVEPOINT(MySQL nested transactions)COMMITon success, orROLLBACKon anyThrowable
The closure pattern used in callers:
$result = null;
$this->inv_service->withTransaction(
function () use ($arg1, $arg2, &$result): void {
// all writes here
$result = ...;
}
);
// use $result here (outside transaction — no writes)$result is captured by reference (&$result) so the caller can access values
computed inside the transaction after it commits. Flash messages and HTTP responses
are always outside the transaction — they are not data writes.
Writes wrapped:
InvService::saveInv()— creates theInvrowInvController::defaultTaxes()— creates oneInvTaxRaterow per default tax rate
Risk without transaction: invoice row committed, then a tax rate validation failure leaves the invoice with no default tax rates. Totals calculate as zero.
Writes wrapped:
InvService::saveInv()— creates the credit invoiceInvAmountService::initializeInvAmount()— creates theInvAmountrowInvController::defaultTaxes()— creates defaultInvTaxRaterows
Risk without transaction: credit invoice exists but amount row is missing, causing null-reference errors in total calculations.
Writes wrapped:
InvService::saveInv()— creates the new credit noteInvItemService::initializeCreditInvItems()— copies all line itemsInvAmountService::initializeCreditInvAmount()— copies the amount recordInvTaxRateService::initializeCreditInvTaxRate()— copies tax ratesbasis_inv->setCreditinvoiceParentId()+iR->save($basis_inv)— links the original invoice back to the credit note
Risk without transaction: new credit note saved, items copied, but if the basis invoice update fails, the original invoice does not record the credit note id — the link between them is broken.
Each invoice in the key list gets its own transaction:
foreach ($keyList as $value) {
withTransaction(function() {
copyInv() // creates Inv row
setDateCreated() + save() // updates date
invToInvInvItems() // copies all line items
invToInvInvTaxRates() // copies tax rates
invToInvInvCustom() // copies custom fields
invToInvInvAmount() // copies amount record
iR->save($copy) // final save
});
}
Per-invoice transactions mean one failed copy does not prevent others in the batch from succeeding.
Writes wrapped:
InvService::saveInv()— creates the new invoiceinvToInvInvItems()— copies line items with amounts and allowance chargesinvToInvInvTaxRates()— copies tax ratesinvToInvInvCustom()— copies custom fieldsinvToInvInvAllowanceCharges()— copies document-level allowance chargesinvToInvInvAmount()— copies the amount recordiR->save($copy)— final save
Any new multi-step write operation should follow the same pattern:
$this->inv_service->withTransaction(function () use (..., &$outVar): void {
// step 1 write
// step 2 write
$outVar = ...; // capture what the caller needs
});
// read $outVar, send responseIf the operation lives in a different service, inject DatabaseManager there and
add the same withTransaction() helper method.