This guide helps you migrate from Twenty CRM PHP Client v0.3 and earlier (hardcoded entities) to v0.4 (dynamic entity system with code generation).
v0.4 introduces a fundamental architectural shift:
- Removed: Hardcoded
Contact,CompanyDTOs and their services - Added: Dynamic entity system that works with ANY Twenty CRM entity
- Added: Code generation tool (
bin/twenty-generate) - Philosophy Change: Library provides tools, users generate entities for their specific Twenty CRM instance
- Every Twenty instance is different - Custom fields, custom entities, custom schemas
- One size fits none - Hardcoded entities couldn't adapt to custom Twenty configurations
- Maintenance burden - Library maintainers shouldn't maintain schema definitions
- Better flexibility - Users can generate entities matching their exact schema
The following classes and methods have been removed:
// ❌ REMOVED in v0.4
use Factorial\TwentyCrm\DTO\Contact;
use Factorial\TwentyCrm\DTO\ContactCollection;
use Factorial\TwentyCrm\DTO\ContactSearchFilter;
use Factorial\TwentyCrm\Services\ContactService;
use Factorial\TwentyCrm\Services\ContactServiceInterface;
use Factorial\TwentyCrm\DTO\Company;
use Factorial\TwentyCrm\DTO\CompanyCollection;
use Factorial\TwentyCrm\DTO\CompanySearchFilter;
use Factorial\TwentyCrm\Services\CompanyService;
use Factorial\TwentyCrm\Services\CompanyServiceInterface;// ❌ REMOVED in v0.4
$client->contacts(); // ContactService
$client->companies(); // CompanyServiceThese helper classes are still available in v0.4:
// ✅ KEPT in v0.4 (used by field handlers)
use Factorial\TwentyCrm\DTO\Phone;
use Factorial\TwentyCrm\DTO\PhoneCollection;
use Factorial\TwentyCrm\DTO\Link;
use Factorial\TwentyCrm\DTO\LinkCollection;
use Factorial\TwentyCrm\DTO\DomainName;
use Factorial\TwentyCrm\DTO\DomainNameCollection;
use Factorial\TwentyCrm\DTO\Name;
use Factorial\TwentyCrm\DTO\Address;
use Factorial\TwentyCrm\DTO\SearchOptions;
use Factorial\TwentyCrm\DTO\CustomFilter;You have two options for migrating to v0.4:
Generate typed entities for your specific Twenty CRM instance.
Advantages:
- ✅ Full IDE autocomplete support
- ✅ Type safety with PHPStan/Psalm
- ✅ Familiar API similar to v0.3 and earlier
- ✅ Commit generated code to your repository
- ✅ Works with custom entities and custom fields
Steps:
-
Install v0.4:
composer require factorial-io/twenty-crm-php-client:^1.0
-
Create configuration file (
.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 # Was "contact" in v0.3 and earlier - company options: overwrite: true
-
Generate entities:
vendor/bin/twenty-generate --config=.twenty-codegen.yaml --with-services --with-collections
-
Update your code:
Before (v0.3):
use Factorial\TwentyCrm\DTO\Contact; use Factorial\TwentyCrm\DTO\ContactSearchFilter; $filter = new ContactSearchFilter(email: 'john@example.com'); $contacts = $client->contacts()->find($filter); foreach ($contacts as $contact) { echo $contact->getEmail(); }
After (v0.4 with generated code):
use MyApp\TwentyCrm\Entities\Person; use MyApp\TwentyCrm\Entities\PersonService; use Factorial\TwentyCrm\DTO\CustomFilter; use Factorial\TwentyCrm\DTO\SearchOptions; $personService = new PersonService( $client->getHttpClient(), $client->registry()->getDefinition('person') ); $filter = new CustomFilter('emails.primaryEmail eq "john@example.com"'); $persons = $personService->find($filter); foreach ($persons as $person) { echo $person->getEmail(); }
-
Commit generated code:
git add src/TwentyCrm/Entities/ git commit -m "Add generated Twenty CRM entities for v0.4"
Use the dynamic entity system without code generation.
Advantages:
- ✅ No code generation needed
- ✅ Works with any entity immediately
- ✅ Flexible for rapid prototyping
- ✅ Adapts automatically to schema changes
Disadvantages:
⚠️ No IDE autocomplete⚠️ No compile-time type checking⚠️ Field names as strings
Example:
Before (v0.3):
use Factorial\TwentyCrm\DTO\Contact;
$contact = new Contact();
$contact->setEmail('john@example.com');
$contact->setFirstName('John');
$contact->setLastName('Doe');
$created = $client->contacts()->create($contact);After (v0.4 with DynamicEntity):
use Factorial\TwentyCrm\DTO\DynamicEntity;
use Factorial\TwentyCrm\DTO\Name;
$definition = $client->registry()->getDefinition('person');
$person = new DynamicEntity($definition, [
'emails' => ['primaryEmail' => 'john@example.com'],
'name' => new Name('John', 'Doe')
]);
$created = $client->entity('person')->create($person);Twenty CRM's default entity is "person", not "contact". Here's how fields map:
| v0.3 and earlier (Contact) | v0.4 (Person) | Type | Notes |
|---|---|---|---|
getEmail() |
getEmail() |
string |
Primary email extracted from emails object |
getFirstName() |
getName()->firstName |
string |
Part of name object |
getLastName() |
getName()->lastName |
string |
Part of name object |
getPhones() |
getPhones() |
PhoneCollection |
Same collection type |
getCompany() |
getCompany() |
Relation | Load with loadRelation('company') |
getPosition() |
getJobTitle() |
string |
Field renamed in Twenty CRM |
getLinkedIn() |
getLinks() |
LinkCollection |
URL extracted from links |
getCity() |
getContactAddress()->city |
string |
Part of address object |
v0.3 and earlier:
$email = $contact->getEmail(); // Direct stringv0.4:
// Option 1: Field handler extracts primary email
$email = $person->getEmail(); // string
// Option 2: Access full emails object
$emails = $person->get('emails'); // ['primaryEmail' => 'john@example.com', ...]v0.3 and earlier:
$firstName = $contact->getFirstName();
$lastName = $contact->getLastName();v0.4:
use Factorial\TwentyCrm\DTO\Name;
// Option 1: Generated getters
$firstName = $person->getFirstName(); // string
$lastName = $person->getLastName(); // string
$fullName = $person->getFullName(); // string (helper method)
// Option 2: Name object
$name = $person->getName(); // Name object
$firstName = $name->firstName;
$lastName = $name->lastName;
$fullName = $name->getFullName();v0.3 and earlier:
$phones = $contact->getPhones(); // PhoneCollection
$primary = $phones->getPrimaryNumber();v0.4:
$phones = $person->getPhones(); // PhoneCollection (same!)
$primary = $phones->getPrimaryNumber();Relations work differently in v0.4:
v0.3 and earlier (hardcoded):
// Relations were not explicitly supported
$companyId = $contact->getCompanyId();v0.4 (with RelationLoader):
// Lazy loading
$company = $person->loadRelation('company');
echo $company->get('name');
// Eager loading
$options = new SearchOptions(limit: 10, with: ['company']);
$persons = $personService->find($filter, $options);
foreach ($persons as $person) {
$company = $person->getRelation('company'); // Already loaded
}v0.3 and earlier:
use Factorial\TwentyCrm\DTO\ContactSearchFilter;
$filter = new ContactSearchFilter(
email: 'john@example.com',
name: 'John'
);v0.4:
use Factorial\TwentyCrm\DTO\CustomFilter;
// Filter syntax follows Twenty CRM API
$filter = new CustomFilter('emails.primaryEmail eq "john@example.com" and name.firstName eq "John"');
// Or use array syntax
$filter = new CustomFilter(null, [
'emails.primaryEmail' => ['eq' => 'john@example.com'],
'name.firstName' => ['eq' => 'John']
]);SearchOptions remain the same:
use Factorial\TwentyCrm\DTO\SearchOptions;
$options = new SearchOptions(
limit: 20,
orderBy: 'createdAt',
orderDirection: 'DESC',
with: ['company'] // NEW: Eager load relations
);Before (v0.3):
$filter = new ContactSearchFilter(email: 'user@example.com');
$contacts = $client->contacts()->find($filter);After (v0.4 - Generated):
$filter = new CustomFilter('emails.primaryEmail eq "user@example.com"');
$options = new SearchOptions(limit: 50);
$persons = $personService->find($filter, $options);After (v0.4 - Dynamic):
$filter = new CustomFilter('emails.primaryEmail eq "user@example.com"');
$persons = $client->entity('person')->find($filter);Before (v0.3):
$contact = new Contact();
$contact->setEmail('new@example.com');
$contact->setFirstName('Jane');
$contact->setLastName('Smith');
$created = $client->contacts()->create($contact);After (v0.4 - Generated):
use MyApp\TwentyCrm\Entities\Person;
use Factorial\TwentyCrm\DTO\Name;
$person = new Person($definition);
$person->setEmail('new@example.com');
$person->setName(new Name('Jane', 'Smith'));
$created = $personService->create($person);After (v0.4 - Dynamic):
$person = new DynamicEntity($definition, [
'emails' => ['primaryEmail' => 'new@example.com'],
'name' => ['firstName' => 'Jane', 'lastName' => 'Smith']
]);
$created = $client->entity('person')->create($person);Before (v0.3):
$contact = $client->contacts()->getById($id);
$contact->setEmail('updated@example.com');
$client->contacts()->update($contact);After (v0.4):
$person = $personService->getById($id);
$person->setEmail('updated@example.com');
$personService->update($person);Before (v0.3):
$contacts = [$contact1, $contact2, $contact3];
$client->contacts()->batchUpsert($contacts);After (v0.4):
$persons = [$person1, $person2, $person3];
$personService->batchUpsert($persons);A: Twenty CRM's default entity is "person", not "contact". The v0.3 and earlier library used "contact" for familiarity, but v0.4 follows Twenty CRM's actual schema.
A: No. Contact, Company, and their services have been removed in v0.4. This is a breaking change requiring migration.
A: No. You can use DynamicEntity for a flexible, runtime approach. Code generation is recommended for better IDE support and type safety.
A: Yes! Re-run vendor/bin/twenty-generate whenever your Twenty schema changes. The generator always reflects your current schema.
v0.3 and earlier: Not possible without library code changes.
v0.4: Works immediately:
// With code generation
vendor/bin/twenty-generate --entities=campaign
// Or use DynamicEntity
$campaign = new DynamicEntity($client->registry()->getDefinition('campaign'), [
'name' => 'Q1 2025 Launch',
'status' => 'ACTIVE'
]);
$client->entity('campaign')->create($campaign);v0.3 and earlier: Custom fields were accessible but not type-safe.
v0.4: Generated code includes ALL fields (standard + custom) with proper types.
# Generate entities matching YOUR exact schema
vendor/bin/twenty-generate --entities=person,companyError handling remains the same:
use Factorial\TwentyCrm\Exception\TwentyCrmException;
use Factorial\TwentyCrm\Exception\ApiException;
try {
$person = $personService->getById($id);
} catch (ApiException $e) {
// Same exception hierarchy as v0.3 and earlier
error_log('API error: ' . $e->getMessage());
}No significant performance difference. The dynamic entity system uses the same HTTP client and request patterns as v0.3 and earlier.
Code generation may be slightly faster due to static property access vs array lookups, but the difference is negligible in real-world usage.
- 1. Backup your code before upgrading
- 2. Review breaking changes in this guide
- 3. Choose migration path: Code generation or DynamicEntity
- 4. Update composer.json:
"factorial-io/twenty-crm-php-client": "^1.0" - 5. Run composer update:
composer update factorial-io/twenty-crm-php-client - 6. If using code generation:
- Create
.twenty-codegen.yamlconfig - Run
vendor/bin/twenty-generate --with-services --with-collections - Commit generated code
- Create
- 7. Update imports:
- Replace
ContactwithPerson(or generated class) - Replace
ContactServicewithPersonService(or generated class) - Replace
ContactSearchFilterwithCustomFilter - Replace
Companywith generatedCompanyclass - Replace
CompanyServicewith generatedCompanyService
- Replace
- 8. Update client calls:
- Replace
$client->contacts()with$personServiceor$client->entity('person') - Replace
$client->companies()with$companyServiceor$client->entity('company')
- Replace
- 9. Update field access:
-
Contact→Personentity name - Check complex fields (emails, name, address) for new structure
- Update relation loading to use
loadRelation()method
-
- 10. Run tests: Verify all functionality works
- 11. Update documentation: Document new entity classes in your project
- Documentation: See updated README.md
- Code Generation Guide: See docs/CODEGEN.md (if available)
- GitHub Issues: Report migration issues
Here's a complete before/after example:
<?php
use Factorial\TwentyCrm\Client\TwentyCrmClient;
use Factorial\TwentyCrm\Auth\BearerTokenAuth;
use Factorial\TwentyCrm\DTO\Contact;
use Factorial\TwentyCrm\DTO\ContactSearchFilter;
use Factorial\TwentyCrm\DTO\SearchOptions;
$client = new TwentyCrmClient($httpClient);
// Search contacts
$filter = new ContactSearchFilter(email: 'john@example.com');
$options = new SearchOptions(limit: 10);
$contacts = $client->contacts()->find($filter, $options);
// Create contact
$contact = new Contact();
$contact->setEmail('new@example.com');
$contact->setFirstName('Jane');
$contact->setLastName('Doe');
$created = $client->contacts()->create($contact);
// Update contact
$contact = $client->contacts()->getById($id);
$contact->setEmail('updated@example.com');
$client->contacts()->update($contact);<?php
use Factorial\TwentyCrm\Client\TwentyCrmClient;
use Factorial\TwentyCrm\Auth\BearerTokenAuth;
use MyApp\TwentyCrm\Entities\Person;
use MyApp\TwentyCrm\Entities\PersonService;
use Factorial\TwentyCrm\DTO\CustomFilter;
use Factorial\TwentyCrm\DTO\SearchOptions;
use Factorial\TwentyCrm\DTO\Name;
$client = new TwentyCrmClient($httpClient);
// Create person service
$personService = new PersonService(
$client->getHttpClient(),
$client->registry()->getDefinition('person')
);
// Search persons
$filter = new CustomFilter('emails.primaryEmail eq "john@example.com"');
$options = new SearchOptions(limit: 10);
$persons = $personService->find($filter, $options);
// Create person
$person = new Person($client->registry()->getDefinition('person'));
$person->setEmail('new@example.com');
$person->setName(new Name('Jane', 'Doe'));
$created = $personService->create($person);
// Update person
$person = $personService->getById($id);
$person->setEmail('updated@example.com');
$personService->update($person);<?php
use Factorial\TwentyCrm\Client\TwentyCrmClient;
use Factorial\TwentyCrm\Auth\BearerTokenAuth;
use Factorial\TwentyCrm\DTO\DynamicEntity;
use Factorial\TwentyCrm\DTO\CustomFilter;
use Factorial\TwentyCrm\DTO\SearchOptions;
$client = new TwentyCrmClient($httpClient);
// Search persons
$filter = new CustomFilter('emails.primaryEmail eq "john@example.com"');
$options = new SearchOptions(limit: 10);
$persons = $client->entity('person')->find($filter, $options);
// Create person
$definition = $client->registry()->getDefinition('person');
$person = new DynamicEntity($definition, [
'emails' => ['primaryEmail' => 'new@example.com'],
'name' => ['firstName' => 'Jane', 'lastName' => 'Doe']
]);
$created = $client->entity('person')->create($person);
// Update person
$person = $client->entity('person')->getById($id);
$person->set('emails', ['primaryEmail' => 'updated@example.com']);
$client->entity('person')->update($person);Version: 0.4 Last Updated: 2025-10-12 Target Audience: Users migrating from v0.3 and earlier to v0.4