Custom CRM IntegrationsPro
You can create your own CRM Integration types and add them to Freeform.
Setup GuideRevised in 5.0+
The creation of a custom CRM integration in Freeform will require using a custom Craft module. This guide assumes you already have that knowledge. If not, please check out the guide we have.
To add a custom CRM integration, you need to create a new class that implements \Solspace\Freeform\Library\Integrations\Types\CRM\CRMIntegrationInterface
interface.
For your convenience, there's a base class that you can extend that implements this interface and implements a function for processing custom fields.
You get this by extending the \Solspace\Freeform\Library\Integrations\Types\CRM\CRMIntegration
class.
Base class
Let's start with a basic CRM integration class and go from there:
<?php
namespace modules\CRM;
use GuzzleHttp\Client;
use Solspace\Freeform\Attributes\Integration\Type;
use Solspace\Freeform\Form\Form;
use Solspace\Freeform\Library\Integrations\Types\CRM\CRMIntegration;
#[Type(
name: 'My Custom CRM Integration',
type: Type::TYPE_CRM,
version: 'v1',
readme: __DIR__.'/README',
iconPath: __DIR__.'/icon.svg',
)]
class CustomCrmIntegration extends CRMIntegration
{
public function checkConnection(Client $client): bool
{
// This method will perform a connection check
// Which will determine if the integration is set up
// successfully.
}
public function getApiRootUrl(): string
{
// This method defines the root API call URL
}
public function fetchFields(string $category, Client $client): array
{
// This method will fetch all custom fields in a given category
}
public function push(Form $form, Client $client): void
{
// This method will perform the call to our API
// pushing all the data submitted by the form
}
}
Here we have a barebones CRM integration type defined. There are 4 methods that have to be implemented for it to work.
Authorization
We likely will need some sort of authorization mechanism to be able to push data to our endpoint. For this example, we will assume that a simple access token is enough. Let's add a property that will store our accessToken
in the database in an encrypted format.
// ...
use Solspace\Freeform\Attributes\Property\Flag;
use Solspace\Freeform\Attributes\Property\Input;
use Solspace\Freeform\Attributes\Property\Validators;
// ...
class CustomCrmIntegration extends CRMIntegration
{
#[Flag(self::FLAG_ENCRYPTED)]
#[Flag(self::FLAG_GLOBAL_PROPERTY)]
#[Validators\Required]
#[Input\Text(
label: 'Access Token',
instructions: 'Enter your Access Token here.',
)]
protected string $accessToken = '';
public function getAccessToken(): string
{
return $this->getProcessedValue($this->accessToken);
}
// ... the rest of the functionality
}
In this example, we added a string $accessToken
property to our class. For it to appear as a text input in the settings page and Form Builder, we add the #[Text]
attribute and provide a label
and instructions
for it.
We add the #[Flag(self::FLAG_ENCRYPTED)]
attribute which will encrypt the value when saving it to the database, and decrypt it when the integration is fetched from the database.
#[Flag(self::FLAG_GLOBAL_PROPERTY)]
attribute makes this input only editable on the integration settings page. If you don't add this attribute, this property will be editable both in the properties and inside the Form Builder for each form separately as well.
#[Validators\Required]
requires this to be set, otherwise you cannot save the integration.
Then we add a getter function that processes the value with the internal ::getProcessedValue()
method. This method parses environment variables so that if you save the access token as an env variable, it will get parsed and the current value will be used.
Making use of the Access Token
Now that we have an access token, we need to attach it to the requests that are being made on behalf of the integration. Let's assume that we need to add an Access-Token
header entry with the access token from the integration. To do this, we will add another event listener, which will let us modify the GuzzleHttp\Client
instance that gets passed to each integration call.
We need to listen to the \Solspace\Freeform\Bundles\Integrations\Providers\IntegrationClientProvider::EVENT_GET_CLIENT
event, so let's edit our module to take care of this:
<?php
namespace modules;
// ...
use yii\base\Event;
use modules\CRM\CustomCrmIntegration;
use Solspace\Freeform\Bundles\Integrations\Providers\IntegrationClientProvider;
use Solspace\Freeform\Events\Integrations\GetAuthorizedClientEvent;
// ...
class Module extends \yii\base\Module
{
public function init(): void
{
// ...
Event::on(
IntegrationClientProvider::class,
IntegrationClientProvider::EVENT_GET_CLIENT,
function (GetAuthorizedClientEvent $event) {
// We check if the current integration class is an instance
// of CustomCrmIntegration
$integration = $event->getIntegration();
if (!$integration instanceof CustomCrmIntegration) {
return;
}
// We add the 'Access-Token' header
$event->addConfig([
'headers' => [
'Access-Token' => $integration->getAccessToken(),
],
]);
}
);
}
}
Once this is done, each time our integration will make an external API call, the GuzzleHttp\Client
will come already pre-configured to have the Access-Token
header, so we won't need to do it manually.
Registering our Integration Type
To make our integration type available in the integration settings when creating new integrations, we need to register it.
In your custom module, we must listen to the \Solspace\Freeform\Services\Integrations\IntegrationsService::EVENT_REGISTER_INTEGRATION_TYPES
event, and register our integration.
<?php
// ...
use Solspace\Freeform\Events\Integrations\RegisterIntegrationTypesEvent;
use Solspace\Freeform\Services\Integrations\IntegrationsService;
// ...
class Module extends \yii\base\Module
{
public function init(): void
{
// ...
Event::on(
IntegrationsService::class,
IntegrationsService::EVENT_REGISTER_INTEGRATION_TYPES,
function (RegisterIntegrationTypesEvent $event) {
$event->addType(CustomCrmIntegration::class);
}
);
}
}
Once that is done, you should be able to see your integration in the dropdown of available integrations.
Testing the connection
Simple API server for testing
To test the connection, we will write a very simple API server for this integration to connect to. Let's make a new PHP file in our modules/ApiMock
folder called server.php
.
Here are the contents of the file:
<?php
header('Content-Type: application/json');
$headers = getallheaders();
$accessToken = $headers['Access-Token'] ?? null;
if ('valid-token' !== $accessToken) {
http_response_code(401);
echo 'invalid token';
exit;
}
$url = $_SERVER['REQUEST_URI'];
echo match ($url) {
default => 'OK',
};
It's super simple. If there's an Access-Token
header provided, and its value is valid-token
, we will return a good connection. If not, we will throw a 401 Unauthorized
with the message invalid token
.
Now let's run the server, by going to the directory where the server.php
is located and starting the PHP's built-in server on localhost:8000
.
$ cd [craft-root-dir]/modules/ApiMock
$ php -S localhost:8000 server.php
You can test if the server is running by opening http://localhost:8000
and seeing the message invalid token
.
Setting up the class to check this connection
Let's write implementations for the ::checkConnection()
and ::getApiRootUrl()
methods:
<?php
// ...
class CustomCrmIntegration extends CRMIntegration
{
// ...
public function checkConnection(Client $client): bool
{
// ::getEndpoint() will generate an endpoint by utilizing
// the ::getApiRootUrl() method to get the root url
// and cleaning up the url and attaching what you specify
// in the argument
// result -> http://localhost:8000
$endpoint = $this->getEndpoint('/');
// we use the passed $client instance, which contains
// the header we attached for the `Access-Token`
$response = $client->get($endpoint);
$status = $response->getStatusCode();
$content = (string) $response->getBody();
// We return true only if the status is 200
// and the response content is 'OK'
return 200 === $status && 'OK' === $content;
}
// This method is usually a simple URL to the api resource
// but in some integrations, the URLs are built based on other
// properties, like specific User ID's, regions, etc
// That can all be handled here for ease of use.
public function getApiRootUrl(): string
{
return 'http://localhost:8000';
}
// ...
}
Now we can create a new integration in the Settings > CRM > New Integration and try out if the token checking works.
Let's start by making an integration with an invalid token:
Here we can see that the integration was not able to authorize because the token is invalid.
Now let's set the value of the token to be valid-token
:
Now the connection is successful. We have a connected integration. Let's move forward.
Fetching custom fields
To be able to fetch custom fields, we will need to implement the ::fetchFields()
method. Each time it is called, it will specify a category to fetch the fields for.
Categories let us split field mappings into separate groups, allowing for easier integration when creating multiple resources within a single integration. For this example though, we will just work with a single category for the sake of simplicity.
First things first, let's update our API Server to return a list of fields when fetching fields. We will return custom fields only for the category contact
:
<?php
// ...
match ($url) {
// ...
'/contacts/fields' => json_encode([
['id' => 1, 'handle' => 'fullName', 'name' => 'Full Name', 'type' => 'text', 'required' => true],
['id' => 2, 'handle' => 'privateEmail', 'name' => 'Private Email', 'type' => 'email', 'required' => false],
['id' => 3, 'handle' => 'companyEmail', 'name' => 'Company Email', 'type' => 'email', 'required' => false],
['id' => 4, 'handle' => 'phone', 'name' => 'Phone', 'type' => 'number', 'required' => false],
]),
'/companies/fields' => json_encode([]),
// ...
}
Now, when we do GET /contact/fields
we will receive a JSON of 4 fields.
Let's implement the fetching logic in our Integration Type
<?php
// ...
class CustomCrmIntegration extends CRMIntegration
{
// ...
public function fetchFields(string $category, Client $client): array
{
// url: /contact/fields
$endpoint = $this->getEndpoint($category.'/fields');
$response = $client->get($endpoint);
$responseFields = json_decode((string) $response->getBody());
$fieldList = [];
foreach ($responseFields as $field) {
// We map the integration's field type
// to the Freeform's field type, which will let
// us handle value transformations later on
$type = match ($field->type) {
'text', 'email' => FieldObject::TYPE_STRING,
'number' => FieldObject::TYPE_NUMERIC,
default => null,
};
// We skip any fields which don't have a matching type
if (null === $type) {
continue;
}
// Map the respective values to those of the FieldObject
$fieldList[] = new FieldObject(
$field->handle,
$field->name,
$type,
$category,
$field->required,
);
}
return $fieldList;
}
}
To be able to see those fields and map them, we must create a FieldMapping
property in our Integration Type class. This will take care of displaying field mappings to the user, as well as be able to handle fetching fields from an endpoint.
The FieldMapping
property is also extremely flexible in letting you decide how it fetches data. It is possible to specify an endpoint in your server where to fetch the data from, and choose which properties currently selected in the form affect the results of your endpoint, by attaching them as URL parameters.
We do have a preset endpoint just for handling CRM field mappings for convenience:
<?php
// ...
use Solspace\Freeform\Attributes\Property\Implementations\FieldMapping\FieldMapping;
use Solspace\Freeform\Attributes\Property\Input;
use Solspace\Freeform\Attributes\Property\Input\Special\Properties\FieldMappingTransformer;
use Solspace\Freeform\Attributes\Property\ValueTransformer;
use Solspace\Freeform\Attributes\Property\VisibilityFilter;
// ...
class CustomCrmIntegration extends CRMIntegration
{
public const CATEGORY_CONTACT = 'contacts';
// ...
#[Flag(self::FLAG_INSTANCE_ONLY)]
#[ValueTransformer(FieldMappingTransformer::class)]
#[VisibilityFilter('Boolean(enabled)')]
#[Input\Special\Properties\FieldMapping(
label: 'Contact Fields',
instructions: 'Select the Freeform fields to be mapped to the applicable Contact fields.',
source: 'api/integrations/crm/fields/'.self::CATEGORY_CONTACT,
parameterFields: [
'id' => 'id',
],
)]
protected ?FieldMapping $contactMapping = null;
// ...
}
The #[Flag(self::FLAG_INSTANCE_ONLY)]
will prevent this field from showing up in the global Freeform > Settings > CRM integrations edit pages, since they’re form-specific. Instead, it will only render the field inside of Form Builder when editing this particular integration.
#[ValueTransformer(FieldMappingTransformer::class)]
this is a transformer class, which is capable of normalizing and denormalizing the value saved to the database. It converts the FieldMapping object to a json value for storing in the database, and then from that json it builds out a FieldMapping object which is set on this instance as a value.
#[VisibilityFilter('Boolean(enabled)')]
this filter allows us to hide this field inside the Builder if the conditions listed there (using javascript) does not match. For instance, if the integration is disabled, the field will not render.
You can add as many visibility filters as you like.
As for the #[FieldMapping] attribute - it requires you to specify the source which is an URL that will be queried for the data. For field mappings you need to specify the response as this type
type Payload = Array<{
id: string;
label: string;
required: boolean;
type: string;
}>;
You can also specify parameterFields which will extract the currently set Form Builder values and pass them as query parameters in the URL that is being queried. In this case we pass the integration id.
We already have a pre-set endpoint built into Freeform for handling CRM field displaying, which we are going to use in this example.
We can now open up the form builder and observe our field mapping showing up, letting us map form fields to the fields of this CRM integration:
Pushing the submitted data to the integration
And now for the most important part of the integration - actually pushing the submitted data. To achieve this, we will implement the ::push()
method. In it we will receive the Form object with all of the currently submitted values as well as the authorized Client object.
<?php
// ...
class CustomCrmIntegration extends CRMIntegration
{
// ...
public function push(Form $form, Client $client): void
{
// Get a [key => value] array of all mapped values
// This will set the key to the CRM field handle
// and set the value to the posted Freeform value
$mapping = $this->processMapping($form, $this->contactMapping, self::CATEGORY_CONTACT);
// Send a POST request to the endpoint with the target email
// and mapped values
$response = $client->post(
$this->getEndpoint('/contacts'),
['json' => [$mapping]],
);
$this->triggerAfterResponseEvent(self::CATEGORY_CONTACT, $response);
}
// ...
}
For testing purposes, let's update our API Server to receive the posted json data and output it to the console, so we can see it in action.
<?php
// ...
// We get the HTTP Method
$method = strtoupper($_SERVER['REQUEST_METHOD']);
// Open up the console output stream
$stdout = fopen('php://stdout', 'w');
if ('POST' === $method) {
// If the url matches `/lists/{id}` route, we handle the case
if (preg_match('/^\\/lists\\/\\d+$/', $url)) {
// Get the posted JSON data and pretty print it
$json = file_get_contents('php://input');
$json = json_encode(json_decode($json, true), \JSON_PRETTY_PRINT);
// Output it to the console
fwrite($stdout, $json.\PHP_EOL);
}
}
// Close the output stream resource
fclose($stdout);
Now when we submit the form, we should receive the data in our console. I mapped the fullName to Name and privateEmail and companyEmail both to a single Email field, and submitted the values John Doe and john@doe.com to the two fields respectively.
This is the result I get in my console.
{
"fullName": "John Doe",
"privateEmail": "john@doe.com",
"companyEmail": "john@doe.com"
}
Resources
The file structure used in this example:
[craft-root]/modules
├── ApiMock
│ └── server.php
├── CRM
│ ├── CustomCrmIntegration.php
│ ├── README.md
│ └── icon.svg
└── Module.php
Contents of the server.php
file:
<?php
header('Content-Type: application/json');
$headers = getallheaders();
$accessToken = $headers['Access-Token'] ?? null;
if ('valid-token' !== $accessToken) {
http_response_code(401);
echo 'invalid token';
exit;
}
$url = $_SERVER['REQUEST_URI'];
$method = strtoupper($_SERVER['REQUEST_METHOD']);
$stdout = fopen('php://stdout', 'w');
if ('POST' === $method) {
// If the url matches `/lists/{id}` route, we handle the case
if ($url === '/contacts') {
// Get the posted JSON data and pretty print it
$json = file_get_contents('php://input');
$json = json_encode(json_decode($json, true), \JSON_PRETTY_PRINT);
// Output it to the console
fwrite($stdout, $json.\PHP_EOL);
}
}
// Close the output stream resource
fclose($stdout);
echo match ($url) {
'/contacts/fields' => json_encode([
['id' => 1, 'handle' => 'fullName', 'name' => 'Full Name', 'type' => 'text', 'required' => true],
['id' => 2, 'handle' => 'privateEmail', 'name' => 'Private Email', 'type' => 'email', 'required' => false],
['id' => 3, 'handle' => 'companyEmail', 'name' => 'Company Email', 'type' => 'email', 'required' => false],
['id' => 4, 'handle' => 'phone', 'name' => 'Phone', 'type' => 'number', 'required' => false],
]),
'/companies/fields' => json_encode([]),
default => 'OK',
};
Contents of the CustomCrmIntegration.php
file:
<?php
namespace modules\CRM;
use GuzzleHttp\Client;
use Solspace\Freeform\Attributes\Integration\Type;
use Solspace\Freeform\Attributes\Property\Flag;
use Solspace\Freeform\Attributes\Property\Implementations\FieldMapping\FieldMapping;
use Solspace\Freeform\Attributes\Property\Input;
use Solspace\Freeform\Attributes\Property\Input\Special\Properties\FieldMappingTransformer;
use Solspace\Freeform\Attributes\Property\Validators;
use Solspace\Freeform\Attributes\Property\ValueTransformer;
use Solspace\Freeform\Attributes\Property\VisibilityFilter;
use Solspace\Freeform\Form\Form;
use Solspace\Freeform\Library\Integrations\DataObjects\FieldObject;
use Solspace\Freeform\Library\Integrations\Types\CRM\CRMIntegration;
#[Type(
name: 'My Custom CRM Integration',
type: Type::TYPE_CRM,
version: 'v1',
readme: __DIR__.'/README.md',
iconPath: __DIR__.'/icon.svg',
)]
class CustomCrmIntegration extends CRMIntegration
{
public const CATEGORY_CONTACT = 'contacts';
#[Flag(self::FLAG_ENCRYPTED)]
#[Flag(self::FLAG_GLOBAL_PROPERTY)]
#[Validators\Required]
#[Input\Text(
label: 'Access Token',
instructions: 'Enter your Access Token here.',
)]
protected string $accessToken = '';
#[Flag(self::FLAG_INSTANCE_ONLY)]
#[ValueTransformer(FieldMappingTransformer::class)]
#[VisibilityFilter('Boolean(enabled)')]
#[Input\Special\Properties\FieldMapping(
label: 'Contact Fields',
instructions: 'Select the Freeform fields to be mapped to the applicable Contact fields.',
source: 'api/integrations/crm/fields/'.self::CATEGORY_CONTACT,
parameterFields: [
'id' => 'id',
],
)]
protected ?FieldMapping $contactMapping = null;
public function getAccessToken(): string
{
return $this->getProcessedValue($this->accessToken);
}
public function checkConnection(Client $client): bool
{
// ::getEndpoint() will generate an endpoint by utilizing
// the ::getApiRootUrl() method to get the root url
// and cleaning up the url and attaching what you specify
// in the argument
// result -> http://localhost:8000
$endpoint = $this->getEndpoint('');
// we use the passed $client instance, which contains
// the header we attached for the `Access-Token`
$response = $client->get($endpoint);
$status = $response->getStatusCode();
$content = (string) $response->getBody();
// We return true only if the status is 200
// and the response content is 'OK'
return 200 === $status && 'OK' === $content;
}
public function getApiRootUrl(): string
{
return 'http://host.docker.internal:8000';
}
public function fetchFields(string $category, Client $client): array
{
// url: /contact/fields
$endpoint = $this->getEndpoint($category.'/fields');
$response = $client->get($endpoint);
$responseFields = json_decode((string) $response->getBody());
$fieldList = [];
foreach ($responseFields as $field) {
// We map the integration's field type
// to the Freeform's field type, which will let
// us handle value transformations later on
$type = match ($field->type) {
'text', 'email' => FieldObject::TYPE_STRING,
'number' => FieldObject::TYPE_NUMERIC,
default => null,
};
// We skip any fields which don't have a matching type
if (null === $type) {
continue;
}
// Map the respective values to those of the FieldObject
$fieldList[] = new FieldObject(
$field->handle,
$field->name,
$type,
$category,
$field->required,
);
}
return $fieldList;
}
public function push(Form $form, Client $client): void
{
// Get a [key => value] array of all mapped values
// This will set the key to the CRM field handle
// and set the value to the posted Freeform value
$mapping = $this->processMapping($form, $this->contactMapping, self::CATEGORY_CONTACT);
// Send a POST request to the endpoint with the target email
// and mapped values
$response = $client->post(
$this->getEndpoint('/contacts'),
['json' => [$mapping]],
);
$this->triggerAfterResponseEvent(self::CATEGORY_CONTACT, $response);
}
}