genesis-global/salesforce-bundle
A Laravel package that streamlines Salesforce integration with configuration, service bindings, and helpers for authenticating, querying, and syncing records through the Salesforce REST API, reducing boilerplate for PHP apps.
Read this first: genesis-global/salesforce-bundle is a Symfony bundle, not a Laravel package. It has no ServiceProvider, no config/*.php, and no package auto-discovery. In practice you will spend most of your time in the underlying REST client it wraps (the genesis-global/salesforce-rest-client library pulled in as a dependency) and treat the bundle as configuration boilerplate you replicate by hand.
Minimal steps
composer require genesis-global/salesforce-bundle
vendor/genesis-global/salesforce-bundle/ and read, in this order:
README.md (auth options and the service name it exposes)DependencyInjection/Configuration.php — the authoritative list of config keysResources/config/services.yml — the actual class graph, i.e. which constructor arguments you must supply manuallyBundle class in Laravel. Instead, replicate its service definitions in an AppServiceProvider. The bundle's own Bundle class extends Symfony\Component\HttpKernel\Bundle\Bundle, which drags in symfony/http-kernel you don't otherwise need.Preferred wiring for Laravel
// config/services.php
'salesforce' => [
'login_url' => env('SALESFORCE_LOGIN_URL', 'https://login.salesforce.com'),
'client_id' => env('SALESFORCE_CLIENT_ID'),
'client_secret' => env('SALESFORCE_CLIENT_SECRET'),
'username' => env('SALESFORCE_USERNAME'),
'password' => env('SALESFORCE_PASSWORD'),
'security_token' => env('SALESFORCE_SECURITY_TOKEN'),
'api_version' => env('SALESFORCE_API_VERSION', 'v52.0'),
],
// app/Providers/SalesforceServiceProvider.php
public function register(): void
{
$this->app->singleton(SalesforceClient::class, function ($app) {
$cfg = $app['config']['services.salesforce'];
return new SalesforceClient(/* args mirroring Resources/config/services.yml */);
});
$this->app->alias(SalesforceClient::class, 'salesforce');
}
Bind as a singleton — that is the single most important decision. Each instantiation triggers a login round-trip against Salesforce, and repeated authentication is what blows through your API call budget.
First use case: a queued job that runs a SOQL query and upserts results into a local table. Prove auth + a simple SELECT Id, Name FROM Account LIMIT 10 end-to-end before building anything layered on top.
Put a gateway between your app and the client. The raw client returns arrays/stdClass and throws generic exceptions. Wrap it once so the rest of the codebase never sees the vendor types:
namespace App\Services\Salesforce;
class SalesforceGateway
{
public function __construct(private SalesforceClient $client) {}
/** @return array<int, array<string, mixed>> */
public function query(string $soql): array
{
$rows = [];
$response = $this->client->query($soql);
while (true) {
array_push($rows, ...$response['records']);
if (empty($response['nextRecordsUrl'])) {
break;
}
$response = $this->client->queryMore($response['nextRecordsUrl']);
}
return $rows;
}
public function upsert(string $sobject, string $externalIdField, array $payload): array
{
return $this->client->upsert($sobject, $externalIdField, $payload);
}
}
Paginating with nextRecordsUrl is mandatory — SOQL silently returns only the first batch (typically 2,000 records) and the client will not loop for you.
Sync out, not in. The common Laravel workflow is Salesforce → local database for read-heavy reporting, and local database → Salesforce for writes. Keep both directions idempotent by always writing to Salesforce via upsert on your own external-ID field rather than create, so a retried job doesn't duplicate records.
One job per object, chunked. Use Bus::batch() with one job per SOQL page or per 200-record write chunk. Salesforce enforces per-transaction and 24-hour API request limits; small chunks make retries cheap and keep failed_jobs readable.
Map to DTOs, not Eloquent directly. Salesforce field names are PascalCase and __c-suffixed, and relationship fields come back as nested objects. A SalesforceAccountData::fromArray() constructor keeps the mapping in one reviewable place and shields your models from API version churn.
Test by faking the gateway, not the HTTP layer. Bind an in-memory implementation of an interface in tests:
$this->app->bind(SalesforceGatewayInterface::class, FakeSalesforceGateway::class);
Salesforce sandboxes are slow and rate-limited; do not point your test suite at one.
Separate credentials per environment. Sandbox uses https://test.salesforce.com, production uses https://login.salesforce.com. Getting this wrong in a .env is the most common cause of "works locally, writes to prod in staging."
composer.json for its PHP and Guzzle constraints before installing — Guzzle 6/7 conflicts with modern Laravel are likely, and PHP 8 type declarations on Symfony's BundleInterface will break the bundle class if you try to register it under Symfony 6 components.Configuration.php. That file is the source of truth for auth mode (password + security token vs. OAuth client_credentials/refresh_token). Read it before guessing env var names.' becomes an injection vector and a syntax error.INVALID_SESSION_ID means your cached token expired (typically after ~2 hours). Detect it and re-authenticate once rather than surfacing a 500. If you see this error frequently, your singleton binding is probably being re-created per request rather than per process — check for scoped vs singleton, and for Octane workers holding stale tokens.success flag and the errors array on each record of a write response; a batch can partially succeed, and the client will not throw.substr($id, 0, 15) for comparisons so you don't create duplicate local rows.salesforce log channel with a low retention window — payloads contain customer data. Log the Sforce-Limit-Info response header; it tells you exactly how many API calls remain per day.SalesforceGateway, not a modification of the vendor client. That way the bundle can be replaced with a hand-rolled client (or a maintained alternative like omniphx/forrest) later without touching call sites.omniphx/forrest or a ~200-line hand-rolled client over Guzzle against the Salesforce REST API will be less work than adapting a dormant Symfony bundle — and you'll own the token-caching and pagination behavior instead of inferring it from YAML.How can I help you explore Laravel packages today?