davispeixoto/force-dot-com-toolkit-for-php
A PHP toolkit for integrating Salesforce Force.com into Laravel or standalone PHP apps, with convenient clients and helpers for authentication, querying, and working with Salesforce data.
Install and confirm prerequisites
composer require davispeixoto/force-dot-com-toolkit-for-php
You need ext-soap enabled and a PHP version the fork actually supports (its composer.json targets older PHP; see Gotchas for PHP 8.x). The package is a namespaced/composer-ised fork of the Salesforce Force.com Toolkit for PHP, so most of the upstream README and blog posts still apply — just swap the classes to Davispeixoto\ForceDotComToolkit\*.
Where to look first
src/SforceEnterpriseClient.php — the class you will use 95% of the time.src/SforceBaseClient.php — login(), create(), update(), upsert(), query(), queryMore(), retrieve(), delete(), describeSObjects(), getUpdated(). This is your API surface.soapclient/*.wsdl.xml — the bundled WSDLs. Do not ship these to production. The Enterprise WSDL is generated per org and encodes your custom objects/fields.examples/ in the repo — the fastest way to see the SObject shape.First use case: push a Lead from a Laravel form
Download your org's Enterprise WSDL from Setup → API → Generate Enterprise WSDL, store it at storage/app/salesforce/enterprise.wsdl.xml, then:
use Davispeixoto\ForceDotComToolkit\SforceEnterpriseClient;
use Davispeixoto\ForceDotComToolkit\SObject;
$client = new SforceEnterpriseClient();
$client->createConnection(storage_path('app/salesforce/enterprise.wsdl.xml'));
$client->login(config('services.salesforce.username'), config('services.salesforce.password').config('services.salesforce.token'));
$lead = new SObject();
$lead->type = 'Lead';
$lead->fields = [
'FirstName' => 'Ada',
'LastName' => 'Lovelace',
'Company' => 'Analytical Engines',
'Email' => '[email protected]',
];
$result = $client->create([$lead]);
if (! $result->success) {
foreach ($result->errors as $error) {
logger()->error('Salesforce create failed', [
'status' => $error->statusCode,
'message' => $error->message,
'fields' => (array) $error->fields,
]);
}
}
If that returns a SaveResult with success === true, you have the whole package working end to end. Everything else is variations on this.
Wrap it in a service, not a facade call. The client holds a session; you want one place that owns construction, login, retry and logging.
// config/services.php
'salesforce' => [
'username' => env('SF_USERNAME'),
'password' => env('SF_PASSWORD'),
'token' => env('SF_TOKEN'),
'wsdl' => env('SF_WSDL', storage_path('app/salesforce/enterprise.wsdl.xml')),
'sandbox' => env('SF_SANDBOX', false),
],
namespace App\Services;
use Davispeixoto\ForceDotComToolkit\SforceEnterpriseClient;
class Salesforce
{
protected ?SforceEnterpriseClient $client = null;
protected function client(): SforceEnterpriseClient
{
if ($this->client) {
return $this->client;
}
$client = new SforceEnterpriseClient();
$client->createConnection(config('services.salesforce.wsdl'));
$client->login(
config('services.salesforce.username'),
config('services.salesforce.password').config('services.salesforce.token')
);
return $this->client = $client;
}
public function __call(string $method, array $args)
{
return $this->client()->{$method}(...$args);
}
}
Bind it as scoped() (not singleton()) so each request/job gets a fresh session, and reset it after a INVALID_SESSION_ID fault.
Upsert for anything that repeats. Retries and re-runs are the normal case; create() will happily make duplicate Leads. Give your records a stable external key:
$obj = new SObject();
$obj->type = 'Contact';
$obj->fields = ['Email' => $email, 'LastName' => $name]; // Email must be an external ID field
$client->upsert('Email', [$obj]);
Batch in 200s. The SOAP API accepts at most 200 records per create/update/upsert/delete call. array_chunk($objects, 200) and check each SaveResult independently — a partial failure returns success = false per record, not an exception.
Query loop that respects done.
$res = $client->query("SELECT Id, Email FROM Contact WHERE LastModifiedDate > {$since}");
$all = is_array($res->records) ? $res->records : [$res->records];
while (! $res->done) {
$res = $client->queryMore($res->queryLocator);
$all = array_merge($all, is_array($res->records) ? $res->records : [$res->records]);
}
Set a sane batch size on the client if exposed, and never SELECT fields you don't need — wide queries hit the 2,000-row-per-batch limit with fatter payloads.
Incremental sync with getUpdated/getDeleted. For nightly mirroring into a Laravel table, getUpdated($sObjectType, $start, $end) is cheaper than polling LastModifiedDate, and getDeleted lets you soft-delete on the local side. Persist latestDateCovered as your next watermark.
Queue the writes. A Salesforce round-trip is 300–1500 ms. Anything user-facing should dispatch a job (ShouldQueue, $tries = 3, backoff = [10, 60, 300]) and not block the request.
Describe before mapping. describeSObjects(['MyObject__c']) gives you field names, picklist values, and types at runtime. Useful for building a mapping layer or validating payloads before you hit a cryptic INVALID_FIELD error.
Test against an interface. Http::fake() does nothing here. Define a SalesforceGateway contract, implement it with the toolkit, and bind a fake in tests. Integration-test the real client against a sandbox Developer org in a separate, manually triggered suite.
The Enterprise WSDL is org-specific and must be regenerated when you add a field. This is the #1 operational surprise. Adding a custom field, then writing to it, gives you a generic SOAP/INVALID_FIELD failure because the WSDL doesn't know the field. Either regenerate + redeploy the WSDL as part of your release process, or use SforcePartnerClient instead — the Partner WSDL is stable across orgs and needs no regeneration, at the cost of weaker typing.
Sandbox vs production login. There's no config switch inside the client; the WSDL carries the login endpoint. A production-generated WSDL points at login.salesforce.com. For sandboxes, either regenerate from the sandbox, or call $client->setEndpoint('https://test.salesforce.com/services/Soap/c/xx.x') after createConnection() and before login(). Getting this wrong means your "sandbox test" writes to production.
The security token is concatenated, not a separate argument. login($user, $password.$token). It's the single most common first-hour bug, and the error message (INVALID_LOGIN) does not tell you which half was wrong.
Sessions die after ~2 hours. You'll get a SoapFault with INVALID_SESSION_ID on a long-running worker. There is no auto-refresh. Wrap your __call so that on that specific fault it discards the client, re-logs-in, and retries once — but only once, to avoid a login-storm loop.
Single-result arrays become scalars. $result->records is an object when exactly one row comes back, an array otherwise. Same for create(), delete(), and errors. Always normalize:
$records = is_array($res->records) ? $res->records : [$res->records];
Nulling a field needs fieldsToNull, not fields. Assigning null in fields is a no-op. $obj->fieldsToNull = ['Description'] is the correct mechanism.
Booleans, dates and numbers are picky. Send dates as Y-m-d and datetimes as ISO 8601 (Y-m-d\TH:i:sP). Booleans should be real booleans; strings like "1"/"true" are unreliable through the SOAP serializer. Currency fields should be numeric strings, not floats with locale formatting.
Turn on SOAP tracing when debugging. The client subclasses SoapClient, so subclass it once and inject ['trace' => 1, 'exceptions' => true] into the WSDL options, then log __getLastRequest() / __getLastResponse(). Without this you get SoapFault with no body and no clue. Related: in local dev, ini_set('soap.wsdl_cache_enabled', '0') — otherwise a regenerated WSDL appears to have no effect because PHP serves the cached one from soap.wsdl_cache_dir.
PHP 8.2+ dynamic properties. SObject and the result classes assign properties dynamically; on PHP 8.2 you'll get deprecation spam (and it's removed in 9). Add #[\AllowDynamicProperties] to SObject in a vendored copy, or pin the package and upgrade PHP deliberately. Test on the exact PHP version you deploy.
Permission errors look like code errors. Before blaming the client, verify with the integration user directly: API Enabled permission, object/field-level access, and that the profile's login IP ranges include your server. INSUFFICIENT_ACCESS_OR_READONLY on a field you can see in the UI usually means FLS, not a bug in your payload.
This is not the Bulk API. SOAP is fine for thousands of records, painful for hundreds of thousands. If a sync grows past ~50k rows per night, stop and switch to Bulk API 2.0 over REST — don't try to make this package do it.
Low adoption, ambiguous license (NOASSERTION). Five stars and zero dependents means no community safety net and no guaranteed fixes. Pin an exact version, commit composer.lock, read the LICENSE file (the upstream toolkit was BSD-3-Clause, but the fork's license metadata is unresolved — get it cleared before shipping commercially), and be prepared to vendor the handful of classes you actually need. For new work, also price out phpforce/soap-client or direct REST/Bulk calls as an escape hatch; the abstraction here is thin enough that migrating is not painful.
How can I help you explore Laravel packages today?