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

Freshdesk Bundle Laravel Package

mpclarkson/freshdesk-bundle

Symfony bundle integrating the Freshdesk helpdesk API, providing a simple, configurable service layer to create and manage tickets, contacts, and agents from your PHP applications without boilerplate.

View on GitHub
Deep Wiki
Context7

Getting Started

Step 0 — accept the stack mismatch before writing code. This is a Symfony bundle (Mpclarkson\FreshdeskBundle), registered through config/bundles.php and a Symfony Extension/Configuration pair. Laravel does not load bundles, so composer require mpclarkson/freshdesk-bundle gives you an installed package with no wiring path. The transferable asset is the underlying client (mpclarkson/freshdesk-api) plus the bundle's tiny DI layer, which is readable in an afternoon and is effectively your spec.

Step 1 — read two files, not the docs. In the repo, look at:

  • src/DependencyInjection/Configuration.php — tells you the exact config keys (domain, api_key, and any others) and their required/optional status.
  • src/Resources/config/services.xml (or the Extension::load method) — tells you the service id and constructor signature of the client: what object you get, what you inject into it.

That's your integration spec. Everything else is the vendor client's model classes.

Step 2 — install the client, not the bundle.

composer require mpclarkson/freshdesk-api

Do not install the bundle in a Laravel app; it will pull symfony/framework-bundle constraints into your lockfile for no benefit.

Step 3 — wire it Laravel-idiomatically.

// config/services.php
'freshdesk' => [
    'domain'  => env('FRESHDESK_DOMAIN'),   // e.g. "acme" for acme.freshdesk.com
    'api_key' => env('FRESHDESK_API_KEY'),
],
// app/Providers/FreshdeskServiceProvider.php
public function register(): void
{
    $this->app->singleton(FreshdeskClient::class, function () {
        $config = config('services.freshdesk');

        return new FreshdeskClient(
            $config['domain'],
            $config['api_key'],
        );
    });

    $this->app->bind(TicketRepository::class, FreshdeskTicketRepository::class);
}

Step 4 — first use case: a read, in tinker.

$tickets = app(FreshdeskClient::class)->tickets->all(['per_page' => 5]);

If that returns data, you have auth, TLS, and JSON parsing working. That is the entire 10-minute smoke test — do it against a sandbox Freshdesk account, never production, on the first run.

Step 5 — immediately wrap it. Do not let vendor types leak into controllers or jobs. Define a TicketRepository interface in your domain, implement it with the client, and inject the interface. Every subsequent pattern below depends on having that seam.


Implementation Patterns

Repository boundary (the one non-optional pattern)

The client returns its own model objects and throws Guzzle exceptions. Both are things you want to be able to swap. Wrap it:

interface TicketRepository
{
    public function find(int $id): Ticket;
    public function openPaginated(int $perPage = 100): Generator;
    public function create(CreateTicket $dto): int;
    public function reply(int $ticketId, string $body): void;
}

FreshdeskTicketRepository translates vendor models into your DTOs, and translates GuzzleHttp\Exception\* into your own FreshdeskUnavailableException / TicketNotFoundException / RateLimitedException. Controllers and jobs only ever see your types. That single indirection is what makes the "swap the client later" option real rather than aspirational.

Reads: lazy pagination, never all() into memory

Freshdesk v2 paginates (default 30, max 100 per page). The client exposes page-based calls; do not loop in a controller. Use a generator and let the consumer decide when to stop:

public function openPaginated(int $perPage = 100): Generator
{
    $page = 1;
    do {
        $batch = $this->client->tickets->all([
            'page'     => $page,
            'per_page' => $perPage,
            'status'   => 2, // open
        ]);

        foreach ($batch as $raw) {
            yield $this->hydrate($raw);
        }

        $page++;
    } while (count($batch) === $perPage);
}

The count($batch) === $perPage termination is deliberate: it lets a short final page end the loop without a second empty request. Check the client's actual return shape — if it returns a wrapper with metadata or link headers, use those instead and trust them over the heuristic.

Writes: queue everything

Freshdesk writes are slow and rate-limited, and a 429 mid-request should not surface to a user. Put every create/update/reply behind a job:

final class SyncTicketToFreshdesk implements ShouldQueue
{
    use Queueable, InteractsWithQueue;

    public int $tries = 5;
    public array $backoff = [10, 30, 60, 120];

    public function handle(TicketRepository $tickets): void
    {
        $tickets->create($this->dto);
    }

    public function middleware(): array
    {
        return [new WithoutOverlapping($this->dto->externalId)];
    }
}

WithoutOverlapping keyed on your own external ID prevents duplicate tickets when a job is retried after a timeout where the write actually succeeded server-side. Freshdesk has no idempotency key on ticket creation, so this is your only defence against duplicates.

Rate limiting: one shared limiter, not ad-hoc sleeps

Freshdesk enforces per-account per-minute limits, and the limit applies across all integrations using the same API key — your queue workers, another team's script, and anything else. Centralize it:

// AppServiceProvider::boot()
RateLimiter::for('freshdesk', fn () => Limit::perMinute(50)->by('global'));

Then in the job: RateLimiter::attempt(...) or wrap the call. Note that Laravel's limiter is per-process-cache; on a multi-worker setup use the Redis cache driver, and size the limit below Freshdesk's actual ceiling so other consumers on the same key have headroom.

Testing: fake at the repository, not the HTTP layer

Because the vendor client uses its own Guzzle instance, Http::fake() will not intercept it. Two workable approaches:

  1. Fake the interface (preferred for unit/feature tests): $this->mock(TicketRepository::class, ...). Fast, no network.
  2. Inject a Guzzle MockHandler into the client for contract tests: build the client with a handler stack containing a mock and recorded Freshdesk responses. This is where you pin pagination behaviour, error shapes, and 429 handling — worth the setup because it is your only detection mechanism for upstream API drift.

If you instead build your own client on Laravel's Http facade, Http::fake() works and this whole section gets simpler.

Multi-tenant / multi-account

If each customer has their own Freshdesk domain, do not bind a singleton. Resolve per tenant:

$this->app->bind(FreshdeskClient::class, fn ($app) => new FreshdeskClient(
    tenant()->freshdesk_domain,
    tenant()->freshdesk_api_key,
));

Store keys encrypted (encrypted cast) — a plaintext API key in a tenants table is an audit finding.

Webhooks are separate scope

This package is request/response only. Inbound "ticket created" events need a public endpoint that verifies Freshdesk's webhook signature, persists the payload, and dispatches a job. Budget it as its own ticket; it is consistently underestimated.


Gotchas and Tips

Composer will fight you on a modern stack. The bundle (and likely the client) carries PHP and Symfony version constraints that predate current runtimes. Run the dependency resolution spike in a throwaway branch before putting it on a roadmap — composer require mpclarkson/freshdesk-api -W --dry-run takes two minutes and is the cheapest possible gate. If it resolves, great; if not, you're writing the client yourself and you have lost nothing.

Auth is HTTP Basic with a quirk. Username is the API key, password is the literal string X (Freshdesk convention). A 401 almost always means a wrong key, a key belonging to a different domain, or a key that was rotated — not a malformed request. Handle 401 explicitly and alert on it; a silent 401 in a sync job produces a "no tickets found" result that looks like success.

Redact the key before anything logs. The client's exception messages may include the request URI, and a naive $e->getMessage() in a log line can leak the key or the auth header. Add a redaction step at your repository boundary — a report() override or a Monolog processor that strips Authorization and any base64 of key:X. Do this before the first deploy, not after the first incident.

429 is not an exception you handle per call. Freshdesk returns 429 with a Retry-After-style hint; the vendor client may or may not respect it. Assume it does not: catch 429 at the job level, release with backoff plus jitter, and rely on the shared limiter to keep you under the ceiling in the first place. Without jitter, a burst of workers all retry at the same second and you get a self-inflicted thundering herd.

Pagination can silently under-report. If a bulk job stops early you get an incomplete dataset with no error — the worst failure mode for a sync. Add a cheap assertion: compare count(processed) against the total Freshdesk reports (list endpoints return a total in metadata where available), and fail loudly on mismatch. Checkpoint progress to a table so a resumed backfill doesn't restart at page 1 or double-write.

Custom fields are name-keyed and unforgiving. Freshdesk custom fields come back under a nested custom_fields object keyed by the field's label-derived key, not its display name. Fetch the field schema once and cache it; hardcoding keys is how you get nulls in production three months later when someone renames a field in the Freshdesk UI.

Timestamps are UTC strings, not objects. The client hands you "2024-03-01T10:22:31Z" style strings. Cast to CarbonImmutable at the hydration boundary and store in UTC. Do not let Carbon::parse() pick up the app timezone in random call sites — you will get off-by-hours bugs that only appear near DST boundaries.

Deletes and empty responses. Some v2 endpoints return 204 No Content or an empty body on success. If you ->json() the response unconditionally you get a confusing parse error. Check the status code before decoding.

The bundle's service definition is your extension point. If you do decide to vendor the bundle (only sane if you also have a Symfony service), the useful seam is the service XML — swap the client class there. In Laravel, the equivalent seam is your service provider binding; keep the client construction in exactly one place so the eventual swap is a one-file change.

No upstream safety net — write the fork procedure down. Five stars, zero dependents, single maintainer. Before you ship, document: where the vendored copy lives, who owns it, how you'd patch an endpoint shape change, and how your contract tests would tell you Freshdesk drifted. MIT means you can fork; the failure mode is nobody remembering that you should.

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.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin