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

Civicrm Core Laravel Package

civicrm/civicrm-core

CiviCRM is open-source CRM software for nonprofits, NGOs, and civic organizations. The core package manages contacts, contributions, memberships, events, cases, campaigns, and reporting, integrates with Drupal, WordPress, and Joomla, and supports extensive customization.

View on GitHub
Deep Wiki
Context7

Getting Started

Do not composer require civicrm/civicrm-core into a Laravel app. It is not a library. The composer.json in that repo exists to build CiviCRM itself (dev tooling, CI, autoload for Civi\ and CRM\ namespaces). A successful composer require leaves you with classes you cannot bootstrap: no civicrm.settings.php, no CIVICRM_UF host, no database, no routing, no auth. Your first architectural decision is that Laravel is a sibling service that talks to CiviCRM over HTTP, not a parent process that hosts it.

Your first hour should be:

  1. Stand up a throwaway CiviCRM with a supported CMS host. The cheapest local path is the upstream buildkit:

    git clone https://github.com/civicrm/civicrm-buildkit.git
    cd civicrm-buildkit && ./bin/civi-download-tools
    civibuild create crmdemo --type drupal-clean --url http://localhost:8000
    cv flush          # clear caches; you will run this constantly
    

    A compose-based WordPress + CiviCRM image is fine too if you prefer containers. Either way, run it as its own site on its own port, not inside your Laravel docroot.

  2. Find the API surface before you write any code. Two URLs matter:

    • http://localhost:8000/civicrm/api4#/explorer — the APIv4 Explorer. This is your contract, your schema browser, and your query debugger in one. Every integration question ("what does a Membership look like?", "which fields are writable?", "what's the join path from Contact to Contribution?") is answered here, live, against real data.
    • http://localhost:8000/civicrm/ajax/api4/Contact/get — the REST endpoint you will actually call from Laravel.
  3. Prove one read end-to-end from Laravel. Do this before anything else; it forces you to solve auth, which is the real blocker:

    // config/services.php
    'civicrm' => [
        'base_url'    => env('CIVICRM_BASE_URL'),          // http://civicrm.test
        'site_key'    => env('CIVICRM_SITE_KEY'),
        'api_key'     => env('CIVICRM_API_KEY'),           // per-user key for legacy auth
        'authx_token' => env('CIVICRM_AUTHX_TOKEN'),       // preferred
    ],
    
    Http::withHeaders(['Authorization' => 'Bearer '.config('services.civicrm.authx_token')])
        ->post(config('services.civicrm.base_url').'/civicrm/ajax/api4/Contact/get', [
            'select' => ['id', 'display_name', 'email_primary.email'],
            'where'  => [['contact_type', '=', 'Individual']],
            'limit'  => 25,
        ])
        ->throw()
        ->json('values');
    

    Install the authx extension (cv ext:enable authx) and mint a JWT/API token rather than leaning on the legacy site_key + api_key pair. Legacy key auth works but it is a shared secret with no expiry and no scoping, and you will regret it the moment a key leaks into a log.

First real use case: read Contacts and Contributions read-only into a Laravel dashboard. Not writes. Reads force you to learn the entity model (Contact → Email → Contribution → LineItem → FinancialItem) at low risk. Writes come second, and only after you've settled idempotency.

Where to look first in the repo: api/v4/ (entity definitions — the authoritative schema), Civi/Api4/ (the entity classes and actions), CRM/Core/ (the DAO/bootstrap layer you should never touch), ang/ (AngularJS admin UI, which you should not extend), and agents/AGENTS.md (upstream's contribution conventions — read it before your first PR).


Implementation Patterns

Pattern 1 — Adapter interface, always

Never scatter Http::post(.../civicrm/ajax/api4/...) through your controllers. One interface, one implementation, one fake:

interface CrmClient
{
    public function contacts(): ContactQuery;
    public function createContact(array $data): int;
    public function contributionsFor(string $externalId): Collection;
}
final class CiviCrmClient implements CrmClient
{
    public function __construct(private PendingRequest $http) {}

    public function createContact(array $data): int
    {
        $result = $this->http->post('/civicrm/ajax/api4/Contact/create', [
            'values' => $data + ['external_identifier' => $data['external_identifier']],
        ])->throw()->json();

        return (int) $result['values'][0]['id'];
    }
}

The interface buys you three things at once: you can run your whole test suite without a CiviCRM instance, you can swap backends later, and — critically — your AGPL coupling stays at the HTTP boundary, which is the safest posture.

Pattern 2 — external_identifier is your join key

CiviCRM has a first-class field for this: Contact.external_identifier. Populate it on creation with your Laravel primary key (user_42, member_1001, a UUID). Every subsequent lookup goes through it:

$contact = $this->http->post('/civicrm/ajax/api4/Contact/get', [
    'select' => ['id', 'external_identifier'],
    'where'  => [['external_identifier', '=', $externalId]],
])->json('values.0');

Do not key on email address. CiviCRM's dedupe rules and Unsupervised matching will happily merge or split contacts based on email, name, and address similarity, and your foreign keys will drift.

Pattern 3 — Laravel outbox, not synchronous writes

Every write to CiviCRM goes through a queued job fed by an outbox table:

final class PushContactToCrm implements ShouldQueue
{
    use Queueable, InteractsWithQueue, Backoff;
    public int $tries = 5;
    public array $backoff = [10, 60, 300, 900, 3600];

    public function __construct(public int $outboxId) {}

    public function handle(CrmClient $crm): void
    {
        $row = Outbox::whereKey($this->outboxId)->lockForUpdate()->firstOrFail();
        if ($row->processed_at) return;                       // idempotency gate

        $crm->createContact($row->payload);                  // external_identifier makes this safe to retry
        $row->update(['processed_at' => now()]);
    }
}

external_identifier plus a uniqueness constraint on the CiviCRM side turns "did it already land?" into a deterministic lookup rather than a guess. Without it, CiviCRM's dedupe engine may create a second contact on retry, and you'll discover it weeks later in a merge queue.

Pattern 4 — APIv4 query building

APIv4's array syntax is expressive but not obvious. The three shapes you'll write daily:

// Join through a related entity without a separate round-trip.
[
  'select' => ['id', 'display_name', 'email_primary.email', 'address_primary.city'],
  'where'  => [
    ['contact_type', '=', 'Individual'],
    ['is_deleted', '=', false],
    ['group_contact.group_id', 'IN', $groupIds],     // implicit join, no join clause needed
  ],
]

// Explicit join with aliases when you need the same entity twice.
[
  'select' => ['c.id', 'a.display_name', 'b.display_name'],
  'from'   => 'Contact',
  'join'   => [
    ['Contact AS a', 'LEFT', ['a.id', '=', 'Contact.employer_id']],
    ['Contact AS b', 'LEFT', ['b.id', '=', 'Contact.related_contact_id']],
  ],
]

// Aggregates — the shape for "donations per contact this year".
[
  'select'   => ['contact_id', 'SUM(total_amount) AS total'],
  'where'    => [['receive_date', '>=', '2024-01-01'], ['financial_type_id:name', '=', 'Donation']],
  'groupBy'  => ['contact_id'],
  'orderBy'  => ['total' => 'DESC'],
  'limit'    => 100,
]

Note field:name pseudo-syntax: financial_type_id:name, contact_id:display_name, email_primary.email. These resolve lookups and joins server-side and are far cheaper than fetching IDs and resolving them yourself. They're documented in the Explorer, not in prose docs — check there first when a field "doesn't exist."

Pattern 5 — Testing without a CRM

Two layers:

  • Laravel side: bind a FakeCrmClient in the container during tests. Zero HTTP, zero network, fast.
  • Contract side: a small Pest/PHPUnit suite tagged @civicrm that hits a real instance (CI service container) and asserts the payloads your adapter produces still match reality. Run it nightly. APIv4 is stable but field names, option values, and permission surfaces do shift between releases.

If you ever contribute upstream, follow agents/AGENTS.md: new tests use $this->createTestEntity(...) and APIv4 entity classes rather than callAPISuccess / callAPIv3Success. And declare agent involvement in the commit — that is an explicit, enforced upstream policy, not a suggestion.

Pattern 6 — cv for provisioning and scripting

cv is the CLI you'll use for everything operational: cv flush after cache changes, cv ext:enable, cv api4 Contact.get +w 'contact_type=Individual', cv scr script.php to run a bootstrapped script. It's also how you seed a fresh environment and how you script one-off data migrations during cutover. Learn it early; it is the difference between a 3-minute and a 30-minute debug loop.

Pattern 7 — Webhooks via a thin extension you own

CiviCRM has no built-in outbound webhook system you'd want to rely on. The supported approach is a small custom extension hooking civicrm_post (entity create/update/delete) and posting signed payloads to Laravel. Keep it thin — the extension's job is to emit an event, not to contain business logic. Every line you put in a CiviCRM extension is a line you must re-verify on every upgrade, and it sits inside the AGPL boundary.

Then reconcile: nightly job compares the last modified_date per entity in both systems and re-queues drift. Webhooks get lost; reconciliation is what makes the sync trustworthy.


Gotchas and Tips

AGPL is a product decision, not a legal footnote. Network copyleft: modify CiviCRM, expose it over a network, and you likely owe corresponding source for the modified work. Pure API consumption from Laravel is generally safe; patching core, bundling, or embedding is not automatically safe. Settle this before engineering commits to anything beyond a spike.

You cannot host CiviCRM inside Laravel. It expects CIVICRM_UF (Drupal, WordPress, Joomla, Backdrop, Standalone). Laravel has no civicrm.settings.php bootstrap path and no user-identity integration. Every attempt to fake it produces a maintenance liability with a short half-life.

Never share the database. CiviCRM owns civicrm_* — views, triggers, its own migration cadence with no Laravel-side awareness. Cross-system SQL reads are a latent outage waiting for an upgrade. HTTP only.

APIv3 is a deprecation glide path. crmApi / CRM.api3 still work and are everywhere in the codebase, but upstream explicitly directs new work (code and tests) to APIv4. Build only against /civicrm/ajax/api4/. If you inherit a codebase calling civicrm/ajax/rest, budget for migration.

Permissions bite silently. APIv4 respects CiviCRM's permission model. A token minted for a low-privilege contact will happily return values: [] rather than an error, and you'll spend an afternoon on it. For server-to-server integration calls, either use a user with administer CiviCRM or pass 'checkPermissions' => FALSE — but understand that you are deliberately bypassing ACLs, so the token must be scoped and stored like a root credential.

Default limit is small and limit => 0 is a memory trap. Always set explicit limit and paginate on offset. get with no limit returns a page, not everything; limit => 0 means "give me all rows" and will OOM on a 200k-contact instance.

Custom fields use the custom_ prefix plus the field ID, not the label. custom_42, not membership_expiry. Resolve IDs at boot via Entity.get / CustomField.get and cache the map — field IDs differ between your dev, staging, and prod databases, so they must never be hardcoded.

Dates and timezones. CiviCRM stores dates in the site timezone, which may differ from Laravel's UTC. Pin both to UTC during the spike and document the decision. receive_date comparisons in where clauses use string dates in Y-m-d H:i:s, and off-by-a-day bugs here are extremely common in contribution reports.

Contributions are not a flat table. A Contribution without a matching LineItem and FinancialItem will appear in the UI but be absent from financial reports and accounting exports. If you create contributions from Laravel, use Contribution.create with the line_items parameter (which cascades) rather than hand-building rows.

Creating a contact may not give you a fresh contact. Dedupe rules run on create. Your "create" can return an existing contact_id that CiviCRM matched. That is usually correct behavior — and exactly why external_identifier and a merge-aware upsert strategy matter. Test against an instance with realistic dupe rules, not a clean one.

PHP version parity is a hard gate. CiviCRM's supported PHP range lags Laravel's floor. Pin the CiviCRM release line first, then choose your Laravel version — never the reverse. Same for the database: MySQL/MariaDB only, no PostgreSQL, and both the CMS host and CiviCRM need compatible configuration.

The AngularJS admin UI is EOL. ang/, the Karma specs, and the lodash-heavy controllers are all on a migration path toward SearchKit/FormBuilder/Afform. Any UI customization you build inside CiviCRM has a shelf life. Build your UI in Laravel and let CiviCRM be a headless data store — this is also the cleanest AGPL posture.

cv flush after touching ang/. Angular modules are declared by a sibling *.ang.php listing js, partials, css, and requires. A new JS file that isn't listed there simply won't load, and an emptied cache is the only way to pick up the change. This trips up everyone once.

Karma tests only run against a live site. karma.conf.js shells out to cv to build the module list, so the suite requires an installed site with cv on PATH. npm test is watch-mode and never exits — use node node_modules/karma/bin/karma start --single-run for CI.

GitHub is a mirror. Development happens on GitLab at lab.civicrm.org. Issues, MRs, and the real commit history live there. Commit subjects lead with dev/core#NNNN when tracing to a GitLab issue. Do not file a GitHub PR and expect it to land.

Declare AI agent involvement in any upstream contribution. agents/AGENTS.md is explicit: agent name, use, and role must be declared in commits and PRs, because "an agent bears no responsibility for its output." Apply the same discipline internally — it's a good tell for reviewability.

Multi-tenancy means N installs. CiviCRM is single-site-per-install. There is no tenant column. Multi-tenant = N databases, N upgrade paths, N backup schedules. Price that in before committing to a per-customer SaaS shape.

Extensions over forks, always. The core is AGPL and community-maintained. Your defensible layer is the Laravel product plus a thin extension — not a private patch set. A fork is a permanent maintenance tax with an upgrade curve that only steepens.

Debugging tip: when an APIv4 call returns something unexpected, reproduce it verbatim in the APIv4 Explorer, which shows the generated SQL. Nine times out of ten the answer is a permission, a :name pseudo-field resolving to an unexpected option value, or a dedupe merge you didn't know happened.

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.
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings