xavicabot/laravel-holded
Laravel wrapper for the Holded API that lets you manage invoices, contacts, products, accounting entries and more from your PHP application. Clean, fluent syntax makes it easy to add invoicing and ERP features to any Laravel project.
Architecture fit
BaseModule centralizes transport concerns, HoldedException normalizes the error contract, and ContactData introduces a DTO boundary at the write path. That is the right shape for a Laravel integration — modules map to API resource groups, config lives in config/holded.php, and resolution happens through the service container.BaseModule extraction is the most consequential structural change. Deduplicating HTTP logic out of DocumentModule and ContactModule means retry/backoff behavior is now uniform across resources. From a governance standpoint this is good: one place to audit timeouts, headers, and retry policy. It also means a defect in BaseModule has blast radius across every module, so the 49-test/98-assertion suite becomes the primary safety net rather than a nicety.HoldedException with getStatusCode(), getResponseBody(), isRateLimited(), isServerError(), isClientError() is a genuine improvement for callers. It replaces Guzzle-exception leakage with a stable contract that application code can branch on without importing Guzzle internals. This reduces coupling to the HTTP transport and is the kind of abstraction that survives a future swap of HTTP client.ContactData) plus union acceptance (ContactData|array) is a pragmatic transitional design. It lets teams adopt validation incrementally without a hard cutover — good for adoption, mildly bad for long-term type clarity, since array remains a permanently supported escape hatch.Integration feasibility
catch (GuzzleException) or generic catch (Exception) around a Holded call needs review. Silent swallowing of Exception will now mask a typed HoldedException and lose the status-code branch points.$retryConfig parameter — any manual new DocumentModule(...) instantiation breaks. Container-resolved usage should be unaffected, which is an argument for auditing for manual instantiation first.Technical risk
POST to create an invoice can produce a duplicate document if the original request succeeded server-side but the response was lost to a 5xx or a gateway timeout. For an accounting system of record this is a data-integrity issue, not a performance one. This must be answered before enabling retries in production.document()->pdf($id) returning base64 implies full buffering of the payload. Large documents inflate memory ~33% over raw size and can push queue workers over memory_limit. No streaming API is described.Key questions
config/holded.php — max attempts, base delay, jitter, and total elapsed cap?HoldedException::getResponseBody() redact anything, and is it safe to write to logs? Bodies can contain customer PII and invoice data.pdf() returning base64 a deliberate constraint of the vendor API, or a package choice that could become a stream?sleep() in-process (unacceptable for web requests) or Laravel's Sleep/queue release?Stack fit
illuminate/support ^11.0|^12.0 and PHP ^8.2. This matches a currently supported Laravel footprint and drops the EOL 8.x/10.x lines cleanly — a defensible support matrix, provided the team's apps are already on 11+.release() backoff rather than in-process HTTP retries, and treat the package retries as a secondary layer.Migration path (v2 → v3)
DocumentModule, ContactModule, GuzzleHttp, GuzzleException, and new <Module>(. Classify container-resolved vs. manually constructed call sites. This step determines the real scope; everything after it is mechanical.illuminate/support below 11.config/holded.php using vendor:publish --force into a diff so retry defaults are visible and reviewed rather than silently accepted.HoldedException; use isRateLimited() / isServerError() / isClientError() and getStatusCode() for branching. Audit bare catch (Exception) blocks specifically — they will now absorb the new typed exception and hide retry exhaustion.$retryConfig is injected from config in one place.ContactData on a per-call-site basis; the ContactData|array union means this is not a blocking step.Compatibility
illuminate/support (e.g., older Laravel Nova, legacy auth packages). Composer resolution failures here surface late and are the most common surprise in this class of upgrade.Sequencing
Maintenance
^3.0) and treat minor bumps as reviewable, not auto-mergeable.Support
HoldedException is a net operational win: errors are now classifiable, so alerting can distinguish rate-limit backpressure from client errors (bad payload — page someone) from server errors (vendor outage — do not page).getResponseBody() in exception messages is convenient and dangerous. Any log shipping or error-tracker integration must confirm vendor bodies are scrubbed or access-controlled, since invoice and contact payloads carry customer PII.Scaling
config/holded.php), so per-tenant or per-account retry tuning is not possible without multiple container bindings. Flag this if the app serves multiple Holded accounts with different limits.Failure modes
| Failure | Symptom | Mitigation |
|---|---|---|
| Retried non-idempotent POST | Duplicate invoices/contacts | Disable write retries until idempotency is confirmed; reconcile via Holded number sequences |
| Retry storm after outage | 429s persist long after recovery | Cap attempts and total elapsed; add jitter; use queue release() |
| Large PDF download | Worker OOM kill | Dedicated queue, memory headroom, size guard before fetch |
| Multipart upload retried | Duplicated or orphaned attachments, slow jobs | Restrict retries to 429 only for uploads; enforce size/MIME limits |
Swallowed HoldedException |
Silent data loss with green dashboards | Audit bare catch (Exception); lint for it |
| Credential expiry (401) | Uniform auth failures across all modules | Confirm refresh behavior; surface 401 distinctly in alerts |
| Vendor response body in logs | PII exposure | Scrub or restrict getResponseBody() at the logging layer |
Ramp-up
How can I help you explore Laravel packages today?