A powerful and flexible PHP client library for interacting with the Twenty CRM API. Unlike traditional CRM clients with hardcoded entities, this library uses a dynamic entity system and code generation to adapt to any Twenty CRM configuration—including custom entities and custom fields.
- What's New in v0.4
- Features
- Requirements
- Installation
- Quick Start
- Project Structure
- Architecture
- Usage
- Entity Relations
- Working with Custom Entities
- Configuration
- API Reference
- Error Handling
- Security
- Testing
- Troubleshooting
- FAQ
- Migration from v0.3
- Contributing
- Changelog
- License
v0.4 represents a major refactoring with breaking changes:
- Namespace Reorganization: Classes moved to dedicated namespaces for better organization
- Filters:
DTO\
→Query\
(CustomFilter, FilterBuilder) - Entities:
DTO\
→Entity\
(DynamicEntity) - Services extracted to
Services\
namespace - Registry extracted to
Registry\
namespace - Collections organized in
Collection\
namespace
- Filters:
- Dynamic Entity System: Work with any Twenty CRM entity without hardcoded classes
- Code Generation: Generate typed entities, services, and collections from your schema
- Enhanced Type Safety: PHPStan level 5 compliance with PHP 8.2+ support
- Improved Error Handling: Dedicated exception hierarchy
- Metadata-Driven Architecture: Automatically adapts to your Twenty CRM schema
Migration Required: See Migration from v0.3 for upgrade instructions.
✅ Dynamic Entity System: Works with any Twenty CRM entity without code changes ✅ Code Generation: Generate typed DTOs, services, and collections for your schema ✅ Custom Entity Support: Campaign, Opportunity, or any custom entity works immediately ✅ Entity Relations: Lazy and eager loading of related entities (Person ↔ Company, etc.) ✅ Type Safety: Full PHP 8.2+ type hints with PHPStan level 5 compliance ✅ Complex Field Handling: Automatic transformation of phones, emails, addresses, links ✅ PSR Compliant: Follows PSR-18 (HTTP Client) and PSR-3 (Logger) standards ✅ Framework Agnostic: Works with any PHP framework or vanilla PHP ✅ Comprehensive Testing: Unit and integration test suites included
- PHP: 8.2 or higher
- Extensions:
json
,mbstring
- HTTP Client: PSR-18 compatible (Guzzle 7+ recommended)
- Twenty CRM: API access with valid authentication token
Optional:
- PSR-3 Logger for debugging (Monolog recommended)
Install via Composer:
composer require factorial-io/twenty-crm-php-client
use Factorial\TwentyCrm\Client\TwentyCrmClient;
use Factorial\TwentyCrm\Auth\BearerTokenAuth;
use Factorial\TwentyCrm\Http\GuzzleHttpClient;
use Factorial\TwentyCrm\Query\CustomFilter;
use Factorial\TwentyCrm\DTO\SearchOptions;
// Setup HTTP client
$guzzle = new \GuzzleHttp\Client(['base_uri' => 'https://api.twenty.com/rest/']);
$httpFactory = new \GuzzleHttp\Psr7\HttpFactory();
$streamFactory = new \GuzzleHttp\Psr7\HttpFactory();
$auth = new BearerTokenAuth('your-api-token');
$httpClient = new GuzzleHttpClient(
$guzzle,
$httpFactory,
$streamFactory,
$auth,
'https://api.twenty.com/rest/'
);
// Create client
$client = new TwentyCrmClient($httpClient);
// Work with any entity dynamically
$filter = new CustomFilter('name.firstName eq "John"');
$options = new SearchOptions(limit: 10);
$persons = $client->entity('person')->find($filter, $options);
foreach ($persons as $person) {
echo $person->get('name')['firstName'] . "\n";
}
The library is organized into dedicated namespaces for better separation of concerns:
Factorial\TwentyCrm\
├── Auth\ # Authentication classes (BearerTokenAuth)
├── Client\ # Main client interface (TwentyCrmClient)
├── Collection\ # Entity collection classes (DynamicEntityCollection)
├── Console\ # CLI commands (code generation)
├── DTO\ # Data transfer objects (Name, Email, Phone, SearchOptions, etc.)
├── Entity\ # Entity classes (DynamicEntity)
├── Enums\ # Enumerations (FieldType, RelationType)
├── Exception\ # Custom exceptions (TwentyCrmException, ApiException)
├── FieldHandlers\ # Field type handlers (transformation logic)
├── Generator\ # Code generation classes
├── Http\ # HTTP client implementation (GuzzleHttpClient)
├── Metadata\ # Entity and field metadata (EntityDefinition, FieldMetadata)
├── Query\ # Filter and query builders (CustomFilter, FilterBuilder)
├── Registry\ # Entity registry (EntityRegistry)
└── Services\ # Service classes (GenericEntityService, MetadataService)
Component | Namespace | Example Classes |
---|---|---|
Queries & Filters | Factorial\TwentyCrm\Query\ |
CustomFilter , FilterBuilder |
Entities | Factorial\TwentyCrm\Entity\ |
DynamicEntity |
Services | Factorial\TwentyCrm\Services\ |
GenericEntityService , MetadataService |
Registry | Factorial\TwentyCrm\Registry\ |
EntityRegistry |
Collections | Factorial\TwentyCrm\Collection\ |
DynamicEntityCollection |
DTOs | Factorial\TwentyCrm\DTO\ |
Name , Email , Phone , SearchOptions |
Metadata | Factorial\TwentyCrm\Metadata\ |
EntityDefinition , FieldMetadata |
Authentication | Factorial\TwentyCrm\Auth\ |
BearerTokenAuth |
HTTP | Factorial\TwentyCrm\Http\ |
GuzzleHttpClient |
Exceptions | Factorial\TwentyCrm\Exception\ |
TwentyCrmException , ApiException |
The library follows a layered architecture:
┌─────────────────────────────────────────┐
│ TwentyCrmClient (Entry Point) │
├─────────────────────────────────────────┤
│ Services Layer │
│ - GenericEntityService │
│ - MetadataService │
├─────────────────────────────────────────┤
│ Registry & Metadata │
│ - EntityRegistry │
│ - EntityDefinition │
├─────────────────────────────────────────┤
│ Query & Filter Layer │
│ - FilterBuilder │
│ - CustomFilter │
├─────────────────────────────────────────┤
│ HTTP Layer │
│ - GuzzleHttpClient (PSR-18) │
│ - BearerTokenAuth │
├─────────────────────────────────────────┤
│ Twenty CRM REST API │
└─────────────────────────────────────────┘
Key Concepts:
- Dynamic Entities: Work with any entity without hardcoded classes
- Metadata-Driven: Automatically adapts to your Twenty CRM schema
- Code Generation: Optional typed entities for better developer experience
- PSR Standards: Follows PSR-3 (logging) and PSR-18 (HTTP client)
The library offers two approaches for working with Twenty CRM entities:
Generate fully-typed entity classes for your Twenty CRM instance.
Advantages:
- ✅ Full IDE autocomplete support
- ✅ Type safety with PHPStan/Psalm
- ✅ Compile-time error checking
- ✅ Familiar object-oriented API
Create .twenty-codegen.yaml
:
namespace: MyApp\TwentyCrm\Entities
output_dir: src/TwentyCrm/Entities
api_url: https://your-twenty.example.com/rest/
api_token: ${TWENTY_API_TOKEN}
entities:
- person
- company
- campaign # Works with custom entities!
options:
overwrite: true
vendor/bin/twenty-generate --config=.twenty-codegen.yaml --with-services --with-collections
This generates:
src/TwentyCrm/Entities/
├── Person.php
├── PersonService.php
├── PersonCollection.php
├── Company.php
├── CompanyService.php
├── CompanyCollection.php
├── Campaign.php
├── CampaignService.php
└── CampaignCollection.php
use MyApp\TwentyCrm\Entities\Person;
use MyApp\TwentyCrm\Entities\PersonService;
use Factorial\TwentyCrm\DTO\Name;
use Factorial\TwentyCrm\Query\CustomFilter;
use Factorial\TwentyCrm\DTO\SearchOptions;
// Create person service
$personService = new PersonService(
$client->getHttpClient(),
$client->registry()->getDefinition('person')
);
// Search persons
$filter = new CustomFilter('emails.primaryEmail eq "[email protected]"');
$options = new SearchOptions(limit: 10);
$persons = $personService->find($filter, $options);
// Create person
$person = new Person($client->registry()->getDefinition('person'));
$person->setEmail('[email protected]');
$person->setName(new Name('John', 'Doe'));
$person->setJobTitle('Developer');
$created = $personService->create($person);
// Update person
$person = $personService->getById($created->getId());
$person->setEmail('[email protected]');
$personService->update($person);
// Get with relations
$options = new SearchOptions(limit: 10, with: ['company']);
$persons = $personService->find($filter, $options);
foreach ($persons as $person) {
$company = $person->getRelation('company');
if ($company) {
echo "{$person->getName()->getFullName()} works at {$company->get('name')}\n";
}
}
Work with entities dynamically without code generation.
Advantages:
- ✅ No code generation step required
- ✅ Works with any entity immediately
- ✅ Adapts automatically to schema changes
- ✅ Perfect for rapid prototyping
Example:
use Factorial\TwentyCrm\Entity\DynamicEntity;
use Factorial\TwentyCrm\Query\CustomFilter;
use Factorial\TwentyCrm\DTO\SearchOptions;
// Get entity definition from registry
$definition = $client->registry()->getDefinition('person');
// Create person
$person = new DynamicEntity($definition, [
'emails' => ['primaryEmail' => '[email protected]'],
'name' => ['firstName' => 'John', 'lastName' => 'Doe'],
'jobTitle' => 'Developer'
]);
$created = $client->entity('person')->create($person);
echo "Created person: " . $created->getId() . "\n";
// Search persons
$filter = new CustomFilter('jobTitle eq "Developer"');
$options = new SearchOptions(limit: 10, orderBy: 'createdAt');
$persons = $client->entity('person')->find($filter, $options);
foreach ($persons as $person) {
echo $person->get('name')['firstName'] . " - " . $person->get('jobTitle') . "\n";
}
// Update person
$person = $client->entity('person')->getById($created->getId());
$person->set('jobTitle', 'Senior Developer');
$client->entity('person')->update($person);
// Delete person
$client->entity('person')->delete($created->getId());
The library supports lazy and eager loading of related entities.
// Load person
$person = $client->entity('person')->getById('person-123');
// Load related company (triggers API call)
$company = $person->loadRelation('company');
echo "Works at: " . $company->get('name') . "\n";
// Load related activities
$activities = $person->loadRelation('activities');
foreach ($activities as $activity) {
echo $activity->get('title') . "\n";
}
use Factorial\TwentyCrm\DTO\SearchOptions;
// Load persons with company relation preloaded
$options = new SearchOptions(
limit: 20,
with: ['company', 'activities'] // Preload relations
);
$persons = $client->entity('person')->find($filter, $options);
foreach ($persons as $person) {
// No additional API call - already loaded
$company = $person->getRelation('company');
if ($company) {
echo "{$person->get('name')['firstName']} works at {$company->get('name')}\n";
}
}
The library automatically discovers and supports all relation types:
- MANY_TO_ONE: Person → Company
- ONE_TO_MANY: Company → People
- MANY_TO_MANY: Campaign ↔ People
- ONE_TO_ONE: Person → Profile
// MANY_TO_ONE: Get person's company
$person = $client->entity('person')->getById('person-123');
$company = $person->loadRelation('company');
// ONE_TO_MANY: Get company's people
$company = $client->entity('company')->getById('company-456');
$people = $company->loadRelation('people'); // Returns array
The library works seamlessly with custom entities without any code changes.
// Works immediately - no configuration needed!
$definition = $client->registry()->getDefinition('campaign');
$campaign = new DynamicEntity($definition, [
'name' => 'Q1 2025 Product Launch',
'status' => 'ACTIVE',
'startDate' => '2025-01-01',
'budget' => 50000
]);
$created = $client->entity('campaign')->create($campaign);
// Search campaigns
$filter = new CustomFilter('status eq "ACTIVE"');
$campaigns = $client->entity('campaign')->find($filter);
foreach ($campaigns as $campaign) {
echo $campaign->get('name') . " - Budget: $" . $campaign->get('budget') . "\n";
}
// Load campaign participants
$participants = $campaign->loadRelation('people');
echo "Participants: " . count($participants) . "\n";
# Add custom entity to config
echo " - campaign" >> .twenty-codegen.yaml
# Generate typed class
vendor/bin/twenty-generate --config=.twenty-codegen.yaml
# Now use with full type safety
use MyApp\TwentyCrm\Entities\Campaign;
use MyApp\TwentyCrm\Entities\CampaignService;
$campaign = new Campaign($definition);
$campaign->setName('Q1 Launch'); // IDE autocomplete!
use GuzzleHttp\Client;
use Factorial\TwentyCrm\Http\GuzzleHttpClient;
use Factorial\TwentyCrm\Auth\BearerTokenAuth;
$guzzle = new Client([
'base_uri' => 'https://api.twenty.com/rest/',
'timeout' => 30,
'headers' => ['User-Agent' => 'MyApp/1.0'],
]);
$httpFactory = new \GuzzleHttp\Psr7\HttpFactory();
$streamFactory = new \GuzzleHttp\Psr7\HttpFactory();
$auth = new BearerTokenAuth('your-api-token');
$httpClient = new GuzzleHttpClient(
$guzzle,
$httpFactory,
$streamFactory,
$auth,
'https://api.twenty.com/rest/'
);
use Factorial\TwentyCrm\Auth\BearerTokenAuth;
$auth = new BearerTokenAuth('your-api-token');
The library supports PSR-3 logging for debugging and monitoring API interactions.
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Factorial\TwentyCrm\Http\GuzzleHttpClient;
use Factorial\TwentyCrm\Client\TwentyCrmClient;
// Create logger
$logger = new Logger('twenty-crm');
$logger->pushHandler(new StreamHandler('php://stdout', Logger::DEBUG));
$logger->pushHandler(new StreamHandler('logs/twenty-crm.log', Logger::INFO));
// Pass logger to HTTP client
$httpClient = new GuzzleHttpClient(
$guzzle,
$httpFactory,
$streamFactory,
$auth,
'https://api.twenty.com/rest/',
$logger // Logger automatically logs all HTTP requests/responses
);
// Pass logger to client (propagates to all services)
$client = new TwentyCrmClient($httpClient, $logger);
// All operations are now logged
$persons = $client->entity('person')->find($filter, $options);
Log Levels:
- DEBUG: API requests/responses, entity operations, service initialization
- ERROR: Authentication failures, API errors, network errors
Example Log Output:
[DEBUG] Twenty CRM client initialized
[DEBUG] Creating entity service {"entity":"person"}
[DEBUG] Finding entities {"entity":"people","filter":"...","options":{...}}
[DEBUG] Twenty CRM API request {"method":"GET","url":"https://...","body":null}
[DEBUG] Twenty CRM API response {"status":200,"body":"..."}
[DEBUG] Found entities {"entity":"people","count":15}
$client = new TwentyCrmClient($httpClient);
// Get generic entity service for any entity
$service = $client->entity('person'); // Returns Services\GenericEntityService
$service = $client->entity('company'); // Returns Services\GenericEntityService
$service = $client->entity('campaign'); // Works with custom entities!
// Access entity registry (metadata)
$registry = $client->registry(); // Returns Registry\EntityRegistry
$definition = $registry->getDefinition('person'); // Returns Metadata\EntityDefinition
$allEntities = $registry->getAllEntityNames();
// Access metadata service
$metadata = $client->metadata(); // Returns Services\MetadataService
$fields = $metadata->getFieldsMetadata('person');
$service = $client->entity('person');
// Find entities
$entities = $service->find($filter, $options); // DynamicEntityCollection
// Get by ID
$entity = $service->getById('uuid'); // DynamicEntity|null
// Create entity
$created = $service->create($entity); // DynamicEntity
// Update entity
$updated = $service->update($entity); // DynamicEntity
// Delete entity
$success = $service->delete('uuid'); // bool
// Batch upsert
$results = $service->batchUpsert([$entity1, $entity2]); // array
Use the composable FilterBuilder for type-safe, validated filters:
use Factorial\TwentyCrm\Query\FilterBuilder;
// Simple filter
$filter = FilterBuilder::create()
->equals('name.firstName', 'John')
->build();
// Multiple conditions (AND)
$filter = FilterBuilder::create()
->equals('status', 'ACTIVE')
->greaterThan('createdAt', '2025-01-01')
->contains('emails.primaryEmail', '@example.com')
->build();
// Multiple conditions (OR)
$filter = FilterBuilder::create()
->useOr()
->equals('status', 'ACTIVE')
->equals('status', 'PENDING')
->build();
// With validation (validates against entity metadata)
$definition = $client->registry()->getDefinition('person');
$filter = FilterBuilder::forEntity($definition)
->equals('status', 'ACTIVE') // Validates enum values
->build();
// Helper methods
$filter = FilterBuilder::create()
->equals('name', 'John') // eq
->notEquals('status', 'DELETED') // neq
->greaterThan('age', 18) // gt
->greaterThanOrEquals('age', 21) // gte
->lessThan('salary', 100000) // lt
->lessThanOrEquals('salary', 50000) // lte
->in('status', ['ACTIVE', 'PENDING']) // in array
->contains('email', '@example.com') // substring
->startsWith('name', 'Jo') // prefix
->isNull('deletedAt') // is NULL
->isNotNull('email') // isNot NULL
->build();
For direct filter string control:
use Factorial\TwentyCrm\Query\CustomFilter;
// String filter (Twenty CRM filter syntax)
$filter = new CustomFilter('name.firstName eq "John"');
$filter = new CustomFilter('emails.primaryEmail contains "@example.com"');
$filter = new CustomFilter('createdAt gt "2025-01-01"');
// Complex filters
$filter = new CustomFilter('status eq "ACTIVE" and budget gt 10000');
See docs/FILTERS.md for complete filter documentation.
use Factorial\TwentyCrm\DTO\SearchOptions;
$options = new SearchOptions(
limit: 20, // Max results
offset: 0, // Pagination offset
orderBy: 'createdAt', // Order by field
orderDirection: 'DESC', // ASC or DESC
with: ['company', 'activities'] // Eager load relations
);
$entity = new DynamicEntity($definition, $data);
// Field access
$value = $entity->get('fieldName'); // Get field value
$entity->set('fieldName', $value); // Set field value
$entity->has('fieldName'); // Check if field exists
$entity->unset('fieldName'); // Remove field
// Array access (alternative syntax)
$value = $entity['fieldName'];
$entity['fieldName'] = $value;
// Relations
$related = $entity->loadRelation('relationName'); // Lazy load
$related = $entity->getRelation('relationName'); // Get if loaded
$hasRelation = $entity->hasLoadedRelation('name'); // Check loaded
$entity->setRelation('relationName', $related); // Set relation
// Serialization
$array = $entity->toArray(); // Export to array
$json = json_encode($entity); // JSON serializable
// Iteration
foreach ($entity as $field => $value) {
echo "$field: $value\n";
}
use Factorial\TwentyCrm\Exception\TwentyCrmException;
use Factorial\TwentyCrm\Exception\AuthenticationException;
use Factorial\TwentyCrm\Exception\ApiException;
try {
$person = $client->entity('person')->getById($id);
} catch (AuthenticationException $e) {
// Handle authentication errors (401, 403)
error_log('Authentication failed: ' . $e->getMessage());
} catch (ApiException $e) {
// Handle API errors (400, 404, 500, etc.)
error_log('API error: ' . $e->getMessage());
error_log('Status code: ' . $e->getStatusCode());
} catch (TwentyCrmException $e) {
// Handle general client errors
error_log('Client error: ' . $e->getMessage());
}
Never commit your API tokens to version control. Use environment variables or secure configuration management:
// ✅ Good: Use environment variables
$token = getenv('TWENTY_API_TOKEN');
if (!$token) {
throw new \RuntimeException('TWENTY_API_TOKEN environment variable is not set');
}
$auth = new BearerTokenAuth($token);
// ✅ Good: Use .env files (with vlucas/phpdotenv)
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
$auth = new BearerTokenAuth($_ENV['TWENTY_API_TOKEN']);
// ❌ Bad: Hardcoded token
$auth = new BearerTokenAuth('your-secret-token-here');
Important: Add .env
files to your .gitignore
:
.env
.env.local
*.token
credentials.json
The library automatically uses HTTPS for all API requests. Always use HTTPS endpoints for production:
// ✅ Good: HTTPS endpoint
$baseUri = 'https://your-instance.twenty.com/rest/';
// ❌ Bad: HTTP endpoint (insecure)
$baseUri = 'http://your-instance.twenty.com/rest/';
Be cautious when logging or displaying error messages in production, as they may contain sensitive information:
try {
$person = $client->entity('person')->getById($id);
} catch (TwentyCrmException $e) {
// ✅ Good: Log to secure location
error_log('Twenty CRM error: ' . $e->getMessage());
// ❌ Bad: Display detailed error to end user
echo 'Error: ' . $e->getMessage(); // May leak API details
// ✅ Good: Generic error message to user
echo 'An error occurred while fetching data. Please try again later.';
}
For multi-tenant applications, create separate client instances with different credentials:
function getTwentyCrmClient(string $tenantId): TwentyCrmClient
{
$token = getTenantApiToken($tenantId); // Fetch from secure storage
$baseUri = getTenantApiUrl($tenantId);
$auth = new BearerTokenAuth($token);
$httpClient = new GuzzleHttpClient(
new \GuzzleHttp\Client(['base_uri' => $baseUri]),
$httpFactory,
$streamFactory,
$auth,
$baseUri
);
return new TwentyCrmClient($httpClient);
}
vendor/bin/phpunit tests/Unit
Unit tests use mocked API responses and don't require credentials.
-
Copy
.env.example
to.env
:cp .env.example .env
-
Add your Twenty CRM credentials:
TWENTY_API_BASE_URI=https://your-instance.twenty.com/rest/ TWENTY_API_TOKEN=your_api_token_here
-
Run integration tests:
vendor/bin/phpunit tests/Integration
Note: Integration tests create and delete real data. Use a test workspace if possible.
For detailed testing documentation, see TESTING.md.
Issue: Class 'Factorial\TwentyCrm\DTO\CustomFilter' not found
Solution: Update your imports to use the new namespace structure:
// ❌ Old (v0.3 and earlier)
use Factorial\TwentyCrm\DTO\CustomFilter;
use Factorial\TwentyCrm\DTO\FilterBuilder;
use Factorial\TwentyCrm\DTO\DynamicEntity;
// ✅ New (v0.4+)
use Factorial\TwentyCrm\Query\CustomFilter; // Moved to Query namespace
use Factorial\TwentyCrm\Query\FilterBuilder; // Moved to Query namespace
use Factorial\TwentyCrm\Entity\DynamicEntity; // Moved to Entity namespace
Note: Most DTOs (Name, Email, Phone, Address, Currency) remain in the DTO
namespace. Only query-related and entity classes moved.
Issue: Entity 'campaign' not found in metadata
Solution: Ensure your API token has access to the entity and that the entity exists:
# Test API access
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://your-instance.twenty.com/rest/metadata/objects
# Verify entity name is correct (case-sensitive)
vendor/bin/twenty-generate --config=.twenty-codegen.yaml --entity=campaign
Issue: Authentication failed: 401 Unauthorized
Solutions:
-
Verify token is correct:
echo $TWENTY_API_TOKEN
-
Check token hasn't expired: Generate a new token in Twenty CRM settings
-
Verify base URI includes
/rest/
:// ✅ Correct $baseUri = 'https://your-instance.twenty.com/rest/'; // ❌ Missing /rest/ $baseUri = 'https://your-instance.twenty.com/';
Issue: Invalid filter syntax
Solution: Use FilterBuilder
for validated filters:
// ❌ Error-prone string filters
$filter = new CustomFilter('name.firstName = "John"'); // Wrong operator
// ✅ Use FilterBuilder for validation
$filter = FilterBuilder::create()
->equals('name.firstName', 'John') // Correct operator (eq)
->build();
For complex filters, see docs/FILTERS.md.
Issue: Relation 'company' not found
Solutions:
-
Check relation name: Use exact field name from metadata:
// Check available relations $definition = $client->registry()->getDefinition('person'); $relations = array_filter($definition->getFields(), fn($f) => $f->isRelation());
-
Use eager loading to avoid N+1 queries:
$options = new SearchOptions(with: ['company', 'activities']); $persons = $service->find($filter, $options);
Issue: No PSR-18 HTTP client found
Solution: Install Guzzle or another PSR-18 compatible client:
composer require guzzlehttp/guzzle
Issue: Parse error: syntax error, unexpected ':'
Solution: This library requires PHP 8.2+. Check your PHP version:
php -v
If you're on PHP 8.1 or lower, upgrade to PHP 8.2 or higher.
Q: Do I need to use code generation?
A: No. The dynamic entity system works without code generation. Use code generation for better IDE support, autocomplete, and type safety.
Q: Can I use this with custom entities?
A: Yes! The library automatically discovers all entities (standard and custom) from your Twenty CRM instance. Just use $client->entity('your-custom-entity')
.
Q: Does this support multi-tenancy?
A: Yes. Create separate TwentyCrmClient
instances for each tenant with different API tokens and base URLs. See the Security section for an example.
Q: How do I debug API calls?
A: Enable logging by passing a PSR-3 logger to the client. See the Logging section for details.
Q: What's the difference between FilterBuilder and CustomFilter?
A: FilterBuilder
provides type-safe filter construction with validation against your entity metadata. CustomFilter
allows direct filter strings for advanced use cases. Use FilterBuilder
unless you need specific filter syntax.
Q: Can I use complex boolean logic (AND/OR)?
A: Yes. Use FilterBuilder
:
// AND (default)
$filter = FilterBuilder::create()
->equals('status', 'ACTIVE')
->greaterThan('age', 18)
->build();
// OR
$filter = FilterBuilder::create()
->useOr()
->equals('status', 'ACTIVE')
->equals('status', 'PENDING')
->build();
Q: How do I filter by date ranges?
A: Use greaterThan
/lessThan
with ISO date strings:
$filter = FilterBuilder::create()
->greaterThanOrEquals('createdAt', '2025-01-01')
->lessThan('createdAt', '2025-02-01')
->build();
Q: When should I use eager loading vs lazy loading?
A:
- Eager loading (preferred): When you know you'll need relations. Loads all data in one request.
$options = new SearchOptions(with: ['company']);
- Lazy loading: For on-demand loading. Makes separate API calls per relation.
$company = $person->loadRelation('company');
Q: Can I load nested relations (e.g., person → company → industry)?
A: Not directly. Load relations in sequence:
$person = $client->entity('person')->getById($id);
$company = $person->loadRelation('company');
$industry = $company->loadRelation('industry');
Q: Do I need to regenerate entities when my schema changes?
A: Yes. Regenerate entities whenever you add/remove fields or entities in Twenty CRM:
vendor/bin/twenty-generate --config=.twenty-codegen.yaml --overwrite
Q: Can I customize generated entity classes?
A: Generated classes shouldn't be manually edited (they'll be overwritten). Instead, extend them:
namespace MyApp\Custom;
use MyApp\TwentyCrm\Entities\Person as BasePerson;
class Person extends BasePerson
{
public function getFullName(): string
{
return $this->getName()->getFullName();
}
}
Q: Can I generate entities for all entities at once?
A: Yes. Omit the entities
key in your config, or use --all
:
# .twenty-codegen.yaml
namespace: MyApp\TwentyCrm\Entities
output_dir: src/TwentyCrm/Entities
api_url: https://your-instance.twenty.com/rest/
api_token: ${TWENTY_API_TOKEN}
# No 'entities' key = generate all
Q: How do I avoid N+1 query problems?
A: Always use eager loading when you know you'll need relations:
// ❌ N+1 problem (1 query + N relation queries)
$persons = $service->find($filter);
foreach ($persons as $person) {
$company = $person->loadRelation('company'); // N queries
}
// ✅ Eager loading (1 or 2 queries total)
$options = new SearchOptions(with: ['company']);
$persons = $service->find($filter, $options);
foreach ($persons as $person) {
$company = $person->getRelation('company'); // No additional query
}
Q: Should I cache entity definitions?
A: The EntityRegistry
automatically caches definitions in memory during a request. For long-running processes (workers, daemons), consider invalidating the cache periodically.
Q: What's the recommended pagination approach?
A: Use limit
and offset
with SearchOptions
:
$perPage = 50;
$page = 1;
$options = new SearchOptions(
limit: $perPage,
offset: ($page - 1) * $perPage,
orderBy: 'createdAt',
orderDirection: 'DESC'
);
v0.4 introduces breaking changes. The hardcoded Contact
and Company
classes have been removed in favor of the dynamic entity system.
See MIGRATION.md for a comprehensive migration guide.
Before (v0.3 and earlier):
use Factorial\TwentyCrm\DTO\Contact;
$contacts = $client->contacts()->find($filter);
After (v0.4 with code generation):
use MyApp\TwentyCrm\Entities\PersonService;
$personService = new PersonService($client->getHttpClient(), $definition);
$persons = $personService->find($filter);
After (v0.4 with dynamic entities):
$persons = $client->entity('person')->find($filter);
For detailed code generation documentation, including configuration options and advanced usage, see the code generation section above or run:
vendor/bin/twenty-generate --help
YAML Configuration (.twenty-codegen.yaml
):
namespace: MyApp\TwentyCrm\Entities
output_dir: src/TwentyCrm/Entities
api_url: https://twenty.example.com/rest/
api_token: ${TWENTY_API_TOKEN}
entities:
- person
- company
- campaign
- opportunity
options:
overwrite: true
# Basic generation
vendor/bin/twenty-generate --config=.twenty-codegen.yaml
# Generate with services and collections
vendor/bin/twenty-generate --config=.twenty-codegen.yaml --with-services --with-collections
# Generate specific entity
vendor/bin/twenty-generate --config=.twenty-codegen.yaml --entity=campaign
# Override options
vendor/bin/twenty-generate --namespace="Custom\\Namespace" --output=custom/path
We welcome contributions! Please see CONTRIBUTING.md for guidelines.
git clone [email protected]:factorial-io/twenty-crm-php-client.git
cd twenty-crm-php-client
composer install
vendor/bin/phpunit tests/Unit
# Run PHPStan (level 5)
vendor/bin/phpstan analyse src
# Run PHPCS (PSR-12)
vendor/bin/phpcs src
# Run PHP CS Fixer
vendor/bin/php-cs-fixer fix
For a detailed history of changes, see CHANGELOG.md.
This project is licensed under the MIT License - see the LICENSE file for details.
Questions or Issues?
- Report bugs: GitHub Issues
- Documentation: README.md | MIGRATION.md | TESTING.md