Salesforce is one of the most common integration targets for enterprise PHP applications. If your Symfony application needs to read or write contacts, leads, accounts, opportunities, or custom objects in Salesforce, the Salesforce REST API provides a well-documented and flexible way to do it.
This guide covers the complete integration from beginning to production. It starts with Salesforce configuration, works through Symfony service setup, covers the most common operations, and addresses the real challenges that most tutorials skip: rate limits, token refresh, error handling, and bidirectional sync.
The code examples here use Symfony 7 with PHP 8.2, but the same approach works with Symfony 6.x and PHP 8.0+. The Salesforce API version used is v59.0.
Choosing the Right Salesforce API
Salesforce offers several APIs with different capabilities. Before writing any code, choose the API that matches your use case.
| API | Best For | Limitations |
|---|---|---|
| REST API | Real-time CRUD operations, most common choice | Rate limited; not ideal for bulk data |
| SOAP API | Legacy integrations, complex queries | Verbose, harder to work with in PHP |
| Bulk API 2.0 | Loading or exporting 50,000+ records | Asynchronous; not for real-time use |
| Streaming API | Real-time notifications of record changes | Requires persistent connection (Bayeux protocol) |
| GraphQL API | Flexible queries, reduce over-fetching | Beta in some orgs; check availability |
For most Symfony integrations — syncing contacts from a web form, creating leads from an API, reading account data for display — the REST API is the right choice. This guide focuses on the REST API.
Step 1: Create a Salesforce Connected App
A Connected App is how Salesforce represents your external application. It is required for OAuth 2.0 authentication. You need Salesforce admin access to create one.
Creating the Connected App
- In Salesforce Setup, search for App Manager in the Quick Find box
- Click New Connected App in the top right
- Fill in Basic Information: Connected App Name (e.g., Symfony Integration), API Name (auto-populated), Contact Email
- Under API (Enable OAuth Settings), check Enable OAuth Settings
- Set Callback URL to your Symfony application's OAuth callback URL (e.g., https://yourapp.com/salesforce/oauth/callback)
- Under Selected OAuth Scopes, add: Access and manage your data (api), Perform requests on your behalf at any time (refresh_token, offline_access)
- Save the Connected App
After saving, Salesforce shows you the Consumer Key and Consumer Secret. These are your OAuth client credentials. Store them securely — you will add them to your Symfony environment variables.
OAuth Flow Choice
Salesforce supports several OAuth flows. For server-to-server integrations (where your Symfony backend calls Salesforce without user involvement), use the JWT Bearer Token flow or the Username-Password flow. For integrations where a human user authorises access through their Salesforce account, use the Web Server OAuth flow.
| Flow | Use Case |
|---|---|
| Web Server (Authorization Code) | User authorises Symfony to access their Salesforce org |
| JWT Bearer Token | Server-to-server; requires certificate setup |
| Username-Password | Service account credentials; simpler but less secure |
| Client Credentials | Available in newer API versions; no user interaction needed |
This guide uses the Username-Password flow for simplicity in demonstrating the integration. For production systems, the JWT Bearer Token flow or Client Credentials flow is recommended because they don't expose user passwords in configuration.
Step 2: Configure Symfony Environment Variables
Store all Salesforce credentials in environment variables. Never hardcode credentials in your Symfony service classes or commit them to version control.
Add the following to your .env.local file (for local development) and your production environment configuration:
- SALESFORCE_LOGIN_URL=https://login.salesforce.com (or https://test.salesforce.com for sandbox)
- SALESFORCE_CLIENT_ID=your_consumer_key
- SALESFORCE_CLIENT_SECRET=your_consumer_secret
- SALESFORCE_USERNAME=your_service_account_username
- SALESFORCE_PASSWORD=your_service_account_password_plus_security_token
- SALESFORCE_API_VERSION=v59.0
Note on the password: when using the Username-Password flow, the password value is your Salesforce password concatenated with your security token (e.g., MyPassword123ABC456xyz). You can reset your security token in Salesforce under Settings → My Personal Information → Reset My Security Token.
Step 3: Install Dependencies
The Symfony integration uses Symfony's HttpClient component to make API calls. Install it if you haven't already:
- composer require symfony/http-client
- composer require symfony/cache (for storing access tokens between requests)
There are also PHP packages specifically for Salesforce integration, including omniphx/forrest (a Laravel-first but Symfony-compatible Salesforce REST library) and developerforce/force.com-toolkit-for-php (older, SOAP-based). For a Symfony application where you want full control over HTTP behaviour, cache strategies, and error handling, building a service class on top of HttpClient is typically the better approach. The examples below use this approach.
Step 4: Build the Salesforce Authentication Service
Create a SalesforceAuthService class responsible for obtaining and caching OAuth access tokens. Every API call to Salesforce requires a valid access token, and tokens expire — so this service needs to handle token refresh transparently.
The authentication service should:
- POST to the Salesforce token endpoint with grant_type, client_id, client_secret, username, and password
- Receive an access_token, instance_url, and token_type in the response
- Cache the access token using Symfony's cache component with a TTL slightly shorter than Salesforce's 2-hour token lifetime
- Return both the access token and instance URL, since the instance URL changes by org and is required for all subsequent API calls
- On a 401 Unauthorized response from any API call, invalidate the cached token and re-authenticate
The instance_url returned during authentication is important. All API calls must go to this URL, not the generic login.salesforce.com URL. A typical instance URL looks like https://yourcompany.my.salesforce.com. Store it alongside the access token in your cache.
Step 5: Build the Salesforce API Service
Create a SalesforceApiService class that handles all REST API calls. This class should depend on SalesforceAuthService to get valid tokens and use Symfony's HttpClientInterface for HTTP requests.
The API service needs to construct the correct URL format for each operation. Salesforce REST API URLs follow this pattern:
- Base URL: {instance_url}/services/data/{api_version}/
- SObject CRUD: {base}/sobjects/{ObjectType}/{id}
- Query (SOQL): {base}/query?q={encoded_soql}
- Search (SOSL): {base}/search?q={encoded_sosl}
- Composite (batch operations): {base}/composite/
All requests require an Authorization header with Bearer {access_token}. All POST and PATCH requests must include Content-Type: application/json. GET and DELETE requests do not require a Content-Type header.
Step 6: Common CRUD Operations
Creating a Salesforce Contact
To create a Contact record, POST to /services/data/v59.0/sobjects/Contact/ with a JSON body containing the field values. Required fields for a Contact are LastName. Other commonly mapped fields include FirstName, Email, Phone, AccountId, and Title.
A successful creation returns HTTP 201 with a JSON body containing the new record's id and a success boolean. Store this Salesforce ID in your local database to link the local record to its Salesforce counterpart.
Reading a Salesforce Record
To read a specific record by ID, GET /services/data/v59.0/sobjects/Contact/{id}. This returns all fields on the object. To retrieve only specific fields and reduce response payload size, use a SOQL query instead:
GET /services/data/v59.0/query?q=SELECT+Id,FirstName,LastName,Email+FROM+Contact+WHERE+Id='003...'
Updating a Salesforce Record
Updates use PATCH, not PUT. PATCH to /services/data/v59.0/sobjects/Contact/{id} with only the fields you want to change. Salesforce performs a partial update — fields not included in the PATCH body are not affected. A successful update returns HTTP 204 with no response body.
Deleting a Salesforce Record
DELETE to /services/data/v59.0/sobjects/Contact/{id}. A successful deletion returns HTTP 204. In most Salesforce configurations, deleting records moves them to the Recycle Bin rather than permanently removing them.
Upserting with External IDs
If you need to create-or-update a record based on an external identifier (for example, your own application's user ID), Salesforce supports upsert via PATCH to /services/data/v59.0/sobjects/Contact/{ExternalIdField}/{externalIdValue}. This requires that the field is designated as an External ID in Salesforce's field configuration. Upsert returns 201 if a record was created and 204 if an existing record was updated.
Step 7: SOQL Queries from Symfony
SOQL (Salesforce Object Query Language) is a SQL-like language for querying Salesforce data. All queries go through the /services/data/v59.0/query endpoint with the SOQL query URL-encoded as the q parameter.
Important SOQL differences from SQL to be aware of:
- No SELECT * — you must list every field you want in the SELECT clause
- Wildcards are not supported in WHERE clauses for equality; use LIKE for partial matches
- JOINs are not supported; related objects are queried via relationship traversal (SELECT Account.Name FROM Contact)
- Results are paginated — the response includes a nextRecordsUrl if there are more records than the default page size (2,000)
- SOQL is case-insensitive for field names but values in string comparisons are case-sensitive by default
Pagination: when a query returns more records than fit in one response, Salesforce includes a nextRecordsUrl in the response JSON. To fetch subsequent pages, GET {instance_url}{nextRecordsUrl}. Continue until the response includes "done": true.
Step 8: Registering the Services in Symfony
Define your SalesforceAuthService and SalesforceApiService in config/services.yaml. Inject environment variables using the %env()% syntax:
- Bind SALESFORCE_LOGIN_URL, SALESFORCE_CLIENT_ID, SALESFORCE_CLIENT_SECRET, SALESFORCE_USERNAME, SALESFORCE_PASSWORD, and SALESFORCE_API_VERSION as constructor arguments
- Set autoconfigure: true so Symfony handles service wiring
- Tag SalesforceApiService with a custom tag if you want to use it in message handlers
For the cache, inject the CacheInterface or use the cache pool tagged as app.cache. Store access tokens with a key like salesforce.access_token and a 110-minute TTL (Salesforce tokens expire in 2 hours; the shorter TTL prevents using a token that's about to expire).
Step 9: Handling Token Expiry and Re-Authentication
Salesforce REST API returns HTTP 401 with error code INVALID_SESSION_ID when an access token has expired. Your SalesforceApiService must handle this gracefully.
The recommended pattern:
- On any 401 response, delete the cached token
- Re-authenticate using SalesforceAuthService to get a fresh token
- Retry the original request once with the new token
- If the retry also fails with 401, throw an exception — something is wrong with credentials
- Log all authentication failures for monitoring
Do not retry indefinitely. A single retry after re-authentication is appropriate. Multiple retries in a loop can cause runaway API calls that exhaust your Salesforce API limits.
Step 10: Handling Salesforce API Rate Limits
Salesforce enforces daily API call limits based on your edition and licence count. Exceeding this limit returns HTTP 403 with REQUEST_LIMIT_EXCEEDED. Monitor your API usage proactively.
Every Salesforce API response includes headers showing your current usage:
- Sforce-Limit-Info: api-usage=12045/15000 — shows calls used and daily limit
- Log this header in your Symfony application to track consumption
- If usage exceeds 80% of daily limit, alert your team
Strategies to stay within limits:
- Use the Composite API to batch up to 25 operations in a single API call
- Cache Salesforce data locally for read-heavy use cases and sync on a schedule rather than querying on every request
- Use Bulk API 2.0 for large data loads instead of individual REST calls
- Avoid querying all fields when you only need a subset — specify fields explicitly in SOQL
- Implement a queue for write operations during high-traffic periods
Step 11: Receiving Salesforce Data in Symfony (Outbound Messaging)
The integration above covers Symfony calling Salesforce. For bidirectional sync, you also need Salesforce to notify Symfony when records change. Salesforce provides two mechanisms for this: Outbound Messaging and Platform Events.
Outbound Messaging
Outbound Messaging is configured in Salesforce Workflow Rules. When a record matches a rule, Salesforce sends a SOAP XML message to an endpoint URL you configure. Your Symfony application exposes an endpoint that receives and processes these messages.
To receive outbound messages in Symfony:
- Create a controller action at a URL like POST /salesforce/webhook/outbound
- Parse the incoming SOAP XML body — the structure depends on the objects you are syncing
- Respond with an ACK (acknowledgement) XML response — Salesforce retries if it does not receive the ACK within 60 seconds
- Process the record data asynchronously using a Symfony messenger handler to avoid timeout issues
- Verify the OrganizationId in the payload matches your Salesforce org to prevent spoofed requests
Platform Events
Platform Events are Salesforce's event-driven integration mechanism. They use the Streaming API with CometD/Bayeux protocol rather than outbound HTTP. For Symfony applications, consuming Platform Events requires a persistent connection to Salesforce's streaming endpoint, which is typically handled by a background worker process rather than an HTTP controller.
A common approach for PHP applications is to use a Salesforce Platform Events consumer library in a Symfony Console command that runs as a long-lived process, or to bridge Platform Events through an external queue (AWS SQS, RabbitMQ) using Salesforce's MuleSoft or a serverless function.
Step 12: Custom Objects and Custom Fields
Working with Salesforce custom objects and custom fields in Symfony requires knowing their API names, which always end in __c (for custom) or __r (for relationship). For example, a custom object named Project has the API name Project__c, and a custom field named Budget has the API name Budget__c.
You can discover all custom object API names and their field definitions using the Salesforce describe endpoint:
- GET /services/data/v59.0/sobjects/ — lists all available SObjects
- GET /services/data/v59.0/sobjects/Project__c/describe/ — returns field definitions, required fields, and relationship metadata for a specific object
Use the describe endpoint during development to understand the schema you are working with. For production code, hardcode the field names you need rather than calling describe on every request.
Error Handling and Salesforce API Error Codes
Salesforce REST API error responses are returned as JSON arrays with an errorCode and message field. Understanding the common error codes prevents you from building generic error handling that masks the real problem.
| Error Code | HTTP Status | Cause | Fix |
|---|---|---|---|
| INVALID_SESSION_ID | 401 | Access token expired or invalid | Re-authenticate and retry |
| REQUEST_LIMIT_EXCEEDED | 403 | Daily API limit reached | Reduce call volume or request limit increase |
| INSUFFICIENT_ACCESS_OR_READONLY | 400 | API user lacks permission on the object/field | Update Connected App or user permissions |
| ENTITY_IS_DELETED | 404 | Record was deleted | Handle gracefully; remove local reference |
| FIELD_INTEGRITY_EXCEPTION | 400 | Required field missing or invalid value | Validate data before sending to Salesforce |
| DUPLICATE_VALUE | 400 | Record with same unique field already exists | Upsert using external ID instead of insert |
| STRING_TOO_LONG | 400 | Field value exceeds Salesforce field length | Truncate before sending |
Build specific exception classes for different error categories: authentication errors, rate limit errors, data validation errors, and not-found errors. This allows calling code to respond appropriately to each case rather than applying a single fallback behaviour to all failures.
Testing the Salesforce Integration
Sandbox vs Production
Always develop and test against a Salesforce sandbox, not production. Change SALESFORCE_LOGIN_URL to https://test.salesforce.com when connecting to a sandbox. Sandbox credentials are separate from production credentials. The Connected App needs to exist in each environment separately or be deployed from production to sandbox via a change set.
Unit Testing
Unit test your SalesforceApiService by mocking Symfony's HttpClientInterface. Inject a mock client that returns predefined responses for authentication and API calls. This lets you test your service logic — pagination handling, error recovery, data mapping — without making real HTTP calls.
Integration Testing
For integration tests, use a dedicated Salesforce developer sandbox with test data. Run integration tests in CI/CD only if you have a reliable way to manage sandbox credentials and test data. Mark integration tests with a Symfony test group (e.g., salesforce) and exclude them from your standard unit test suite so they don't run on every commit.
Securing the Integration
The most common security vulnerabilities in Salesforce integrations:
- Storing credentials in code or committed to version control — use environment variables and secrets managers
- Granting the integration user admin-level Salesforce permissions — create a dedicated integration user with only the object and field permissions the integration actually needs
- Not verifying Salesforce outbound message payloads — validate the OrganizationId to ensure the message is from your Salesforce org
- Logging access tokens in application logs — never log token values, even at debug level
- Not using HTTPS for your Symfony webhook endpoint — Salesforce requires HTTPS for outbound message endpoints; self-signed certificates will be rejected
How Nurture Technologies Can Help
Symfony Salesforce integrations look straightforward from the API documentation, but production-grade implementations involve decisions that aren't obvious until something breaks in production: how to handle the 401 token expiry during high-traffic periods without stampeding re-authentication requests, how to implement bidirectional sync without creating infinite loops, how to manage API limits across multiple application instances, and how to structure the service layer for testability.
At Nurture Technologies, we build and maintain custom Salesforce integrations for Symfony and other PHP frameworks. Whether you need a new integration built correctly from the start or an existing integration that is fragile or poorly structured to be refactored into a maintainable service, we can help.
Need a Reliable Symfony Salesforce Integration?
We build production-ready Salesforce integrations for Symfony applications. If you are starting a new integration or dealing with one that has become hard to maintain, we can review your architecture and implement a clean, tested solution.
Conclusion
A Symfony Salesforce integration built on the REST API is reliable and maintainable when the service layer is designed correctly. The main things to get right are: OAuth token management with proper caching and refresh handling, specific error code handling rather than generic catch-all fallbacks, rate limit awareness through header monitoring and batching, and a test suite that covers the critical paths without requiring a live Salesforce connection for unit tests.
The pattern that fails most often in production is a naive implementation that re-authenticates on every request, ignores rate limit headers, and has no retry logic. With proper token caching, the Composite API for batch operations, and a retry layer that handles the INVALID_SESSION_ID error code, the same integration becomes significantly more robust.
Start with the Username-Password flow for development speed, then migrate to JWT Bearer Token or Client Credentials in production for better security. Test against a sandbox first, validate the schema using the describe endpoint, and deploy to production only after confirming the integration works correctly end-to-end in a staging environment.