Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Holded Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Upgrade path (v2 → v3). Before anything else:

  1. Bump PHP to ^8.2 and Laravel to 11.x or 12.x.
  2. composer update and publish the new config: php artisan vendor:publish --tag=holded-config --force.
  3. Grep your codebase for GuzzleHttp\Exception / RequestException catches around this package and swap them for HoldedException.
  4. Grep for new DocumentModule( / new ContactModule( — the constructor signature changed ($retryConfig added). If you resolve modules from the container this is a no-op; if you instantiate them manually, fix or drop those call sites.

First steps for a fresh install.

composer require <vendor>/holded
php artisan vendor:publish --tag=holded-config

Add HOLDED_API_KEY=... to .env and confirm the base URL / timeout / retry block in config/holded.php. There is no OAuth dance — it's an API key, so you should be making requests within a couple of minutes.

First use case — the one you'll actually write on day one: create a contact, then create a document against it.

use Holded\DTO\ContactData;

$contact = Holded::contact()->create(new ContactData(
    name: 'ACME SL',
    email: '[email protected]',
    type: 'client',
    countryCode: 'ES',
));

Where to look first, in order: config/holded.php (retries + timeouts are the only knobs you'll touch), HoldedException (it's now the single error surface, so learn its four helpers), ContactData (the only DTO so far — it defines the validation contract the other modules will likely copy), and BaseModule (all HTTP behaviour lives there; if you need to understand retries or multipart upload, read it before reading the modules).

Implementation Patterns

DTO-or-array everywhere. create()/update() accept ContactData|array. Standardise on the DTO internally and accept arrays only at your own controller boundary:

public function store(StoreContactRequest $request)
{
    $contact = Holded::contact()->create(
        ContactData::from($request->validated())   // normalises + validates locally first
    );
}

The win here isn't ergonomics, it's that validation failures throw before the HTTP call. That means a malformed countryCode is a local ValidationException, not a 422 round-trip with an opaque body.

Centralised error translation. Because HoldedException now wraps everything, you can write one handler and stop thinking about it:

// app/Exceptions/Handler.php or a dedicated listener
try {
    Holded::document()->pdf($id);
} catch (HoldedException $e) {
    report($e);

    return match (true) {
        $e->isRateLimited() => response()->json(['error' => 'Busy, retry shortly'], 429),
        $e->isClientError() => response()->json(['error' => 'Invalid document'], 422),
        $e->isServerError() => response()->json(['error' => 'Holded unavailable'], 503),
    };
}

Log $e->getStatusCode() and $e->getResponseBody() together — the body is where Holded's actual field-level complaint lives, and it's not mirrored any other way.

Let the retries do their job, don't double-retry. 429s now back off exponentially and 5xx linearly, driven by config/holded.php. If you also have ->retry() on the job or Http::retry() around it, you'll multiply attempts. Pick one layer. Generally: keep retries in the package (it knows the backoff intent), keep tries on the job at 1, and use the queue only for concurrency/scheduling.

Queued document generation. PDF download and attachment are the two calls most likely to be slow, so push them off the request cycle:

class StoreDocumentPdf implements ShouldQueue
{
    public function handle(): void
    {
        $base64 = Holded::document()->pdf($this->documentId);
        Storage::disk('s3')->put("pdfs/{$this->documentId}.pdf", base64_decode($base64));
    }
}

Keep the job payload to the ID, not a live module instance — modules aren't serialisable and the container resolution is cheap.

Streaming a PDF back to the browser without touching disk:

$base64 = Holded::document()->pdf($id);

return response()->streamDownload(
    fn () => print(base64_decode($base64)),
    "invoice-{$id}.pdf",
    ['Content-Type' => 'application/pdf'],
);

Attachments as a follow-up step. attach() uses multipart/form-data and takes a local path, so it composes naturally after document creation:

$document = Holded::document()->create([...]);
Holded::document()->attach($document->id, storage_path("tmp/{$upload->hashName()}"));

Testing. With 49 tests upstream you can trust the happy paths; don't duplicate them. Test your integration layer by faking HTTP responses and asserting your handler produces the right status/shape — and add one test per HoldedException branch, since the exception type is now the contract you depend on.

Config-driven environments. Because retries and timeouts are config, you can dial retries to 0 in your test suite (fail fast, deterministic) and higher in production. Do that explicitly in phpunit.xml rather than hoping the default is fast enough.

Gotchas and Tips

Retries on non-idempotent writes are the one to verify before shipping. The release notes say 429 and 5xx are retried automatically; they don't say whether POST is excluded. If create() retries after a 500 that actually succeeded server-side, you get duplicate documents. Open BaseModule and confirm the retry predicate — and if it doesn't exclude POST, either disable retries for creation paths or add an idempotency key at your layer. This is the highest-risk item in the release.

Catch blocks that silently stopped working. Any catch (RequestException $e) around this package is now dead code — Guzzle exceptions no longer bubble. HoldedException extends whatever it extends, so a broad catch (\Throwable) still catches it (good), but a Guzzle-specific catch will not (bad, and it fails open, not closed). Sweep for this during upgrade; it's the classic "our error handling stopped firing" bug.

pdf() returns base64, not bytes. It's a string, it's ~33% larger than the file, and it will blow up memory on large documents if you hold many at once. Always base64_decode() before writing or streaming, and prefer streamDownload over buffering into a response string.

attach() takes a file path, not an UploadedFile. You need a real path on disk. For request uploads that means either storing to a temp location first or using $file->getRealPath() inside the request lifecycle — the latter fails once the request ends, so never defer the attach to a queued job with only the UploadedFile in the payload. Store the file, queue the path.

Module constructors are now three-arg. The $retryConfig parameter is appended, so code doing new DocumentModule($client) breaks. Container resolution is fine; manual new is not. If you have custom modules extending BaseModule, re-check your parent::__construct() call.

Timeout vs. retry interaction. With N retries and a T-second timeout, worst-case latency is roughly N × (T + backoff). In a web request that's a 504 from your own server before Holded ever times out. Either keep retry counts low for synchronous calls or, better, set a shorter per-attempt timeout when retries are enabled. Check what the config defaults are — packages frequently ship a generous combined worst case.

isClientError() is a category, not a diagnosis. 401 (bad key), 404 (wrong ID), and 422 (bad payload) are all true. Don't collapse them into one user-facing message; branch on getStatusCode() when the UI needs to say something useful.

Don't retry on 4xx. If you've added any retry logic of your own, make sure it's gated on isRateLimited() || isServerError(). A 401 retried ten times is just ten failed auth attempts and, on some APIs, an account lockout.

Rate-limit backoff is invisible by default. Exponential backoff on 429 means a burst of writes can appear to hang for several seconds with no log line. Add a listener or a debug log when a retry fires — otherwise you'll spend an afternoon blaming the network. This is the main "debugging" gap in the release.

Validation lives in the DTO, so bypassing it bypasses validation. Passing arrays straight through skips ContactData's normalisation. That's fine if you've validated upstream with a FormRequest, but it's an easy way to send a countryCode in the wrong case or an unnormalised type and get a 422 you can't reproduce locally.

Pin the major version. ^3.0 in composer.json, and read the changelog before ^4. This release broke on exception type, constructor signature, and PHP floor simultaneously — the maintainers are willing to make breaking changes, and the package touches money-adjacent data (documents, invoices, PDFs). Budget a review on each major.

Extension point: BaseModule is now the single place HTTP behaviour is defined. If you need custom headers, a different timeout, or per-call retry overrides, that's the class to look at — but prefer composing around the modules (a decorator that wraps calls and adds tracing) over forking them, since BaseModule is exactly the kind of shared code that will churn in v4.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel
vish4395/laravel-translation-manager
morningtrain/economic
teksite/systeminfo-laravel
atomcms/voting
run6/weather
siokas/laravelembeddirectives
adamlien/pkuuids
gilsonsouza/lara-doc-generator
manelgavalda/enrollment_mobile
unabomber/timezone
delatbabel/contacts
astritzeqiri/g-recaptcha
kevinongko/laravel-genode
developeruz/laravel-analytics