xrow/soap-client
A SOAP client package for Laravel and PHP that simplifies building and sending SOAP requests, working with WSDL definitions, and handling responses. Integrates with Laravel's container and configuration for cleaner, more testable web service integrations.
Install and satisfy the hard requirement. The package is a thin wrapper over PHP's SoapClient, so ext-soap must be enabled — this is the single most common first-run failure.
composer require xrow/soap-client
php -m | grep soap # must print "soap"
Get a WSDL before you write any code. Salesforce does not expose a public WSDL — it is generated per-org. In Salesforce: Setup → API → Generate Enterprise WSDL (typed against your org's objects and custom fields) or grab the Partner WSDL (untyped, generic sObject, works across orgs). Drop the file in storage/app/salesforce/ and keep it out of git.
Where to look first. Read the client class source before binding it — the library is small, unmaintained since 2015, and has essentially zero downstream users, so there is no community documentation to fall back on. You will learn more from the 150 lines of source than from any README. Confirm the constructor signature (WSDL path, options array) and whether it exposes a login() helper or expects you to call SoapClient::__soapCall directly.
First use case — authenticate and read. Wire credentials through config, bind the client as a singleton, and prove connectivity with a single query():
// config/salesforce.php
return [
'wsdl' => storage_path('app/salesforce/enterprise.wsdl'),
'username' => env('SF_USERNAME'),
'password' => env('SF_PASSWORD'),
'token' => env('SF_SECURITY_TOKEN'),
'endpoint' => env('SF_ENDPOINT', 'https://login.salesforce.com'),
'cache' => storage_path('framework/cache/wsdl'),
];
$this->app->singleton(SalesforceClient::class, fn () => new SalesforceClient(config('salesforce')));
// first smoke test
$result = app(SalesforceClient::class)->query('SELECT Id, Name FROM Account LIMIT 5');
If that returns records, everything else is a variation on it.
Never inject the client into a controller. The client is stateful (it holds a session ID and a serverUrl returned by login()), so treat it as infrastructure. Put a SalesforceService or per-object repository in front of it and inject that. This also gives you one seam to fake in tests.
Cache the session, not just the client. login() consumes an API call and returns a session ID plus an instance URL that expires (two hours by default). Store the pair in the cache with a TTL comfortably under the timeout, and re-authenticate on INVALID_SESSION_ID:
$session = Cache::remember('sf:session', now()->addMinutes(90), fn () => $this->client->login());
Page query() explicitly. A single query() returns at most 2,000 records and a queryLocator. queryMore($locator) walks the rest. Wrap this in a generator so callers never see the locator:
foreach ($this->salesforce->each('SELECT Id, Email FROM Contact WHERE ...') as $record) { ... }
Batch writes at 200 records. Salesforce caps a single SOAP create/update/upsert call at 200 objects. Chunk with collect($rows)->chunk(200) and send one call per chunk, collecting per-record errors rather than aborting the batch — Salesforce happily accepts 199 and rejects 1.
Push outbound work onto the queue. API calls are slow (200–800 ms typical) and rate-limited by a rolling 24-hour org allocation. Dispatch a job per sync; add retry() with exponential backoff and use RateLimited middleware if the org is shared with other integrations.
Inbound: use Outbound Messages as the trigger. Salesforce can POST a SOAP message to a Laravel route on record change. Accept it, return the SOAP acknowledgement envelope immediately, and hand the payload to a job — Salesforce retries non-2xx responses for 24 hours, so a slow handler will get you duplicate deliveries.
Pick the right WSDL per job. Enterprise is typed and pleasant to work with but breaks whenever an admin adds a custom field — the WSDL must be regenerated and redeployed. Partner is stable but everything arrives as generic sObject/stdClass. Most teams use Enterprise for a fixed set of objects and Partner for the one integration that must survive schema churn.
Cache describeSObject() results. Field metadata changes rarely, takes seconds to fetch, and is usually only needed for dynamic mapping. A daily cache entry is fine.
$str{0}, fatal in PHP 8), missing declare(strict_types=1), and any hardcoded API version string. If it is a few hundred lines, consider vendoring it or copying the useful bits into app/Services and dropping the dependency — you lose nothing but an update path that does not exist anyway.29.0, calls begin failing with INVALID_API_VERSION well before the code looks old. Make the version configurable or patch the WSDL's version attribute at boot.wsdl_cache and point cache_wsdl / wsdl_cache_dir at a writable path — the default sys_get_temp_dir() is often unwritable in containers and fails with a confusing SOAP-ERROR: Parsing WSDL.SOAP Fault is not Exception. Catch \SoapFault and map errorCode strings (INVALID_FIELD, ENTITY_IS_DELETED, UNABLE_TO_LOCK_ROW, REQUEST_LIMIT_EXCEEDED) into your own exception hierarchy. UNABLE_TO_LOCK_ROW is transient — retry it; INVALID_FIELD is not."true"/"false" and dates as ISO-8601 with a timezone offset; cast explicitly on the way in and always send DateTime in UTC. null fields are omitted from responses rather than returned as null, so use data_get($record, 'Field') rather than property access.INVALID_FIELD on write or a silently absent key on read. Check the profile before debugging your code.SoapClient is final-ish and awkward to mock, define a SalesforceGateway interface, bind the package behind it, and swap a fake in tests. Do not attempt to stub __soapCall.How can I help you explore Laravel packages today?