
Espocrm
- 72 installs
- 63 repo stars
- Updated July 18, 2026
- bobmatnyc/claude-mpm-skills
EspoCRM is a Claude skill that guides developing custom modules, entities, hooks, and API endpoints for the EspoCRM metadata-driven PHP CRM using its service layer and ORM EntityManager.
About
A development skill for building custom modules, entities, hooks, and integrations on EspoCRM, a metadata-driven PHP CRM. It enforces architectural rules: business logic in Services, data access via the ORM EntityManager, and constructor dependency injection. A developer uses it when extending EspoCRM with custom entities, API actions, or field types. It exists to prevent common architectural mistakes.
- Enforces EspoCRM's metadata-driven, service-layer architecture
- Covers EntityManager data access, hooks, custom fields, and API actions
- Prevents anti-patterns like business logic in hooks or direct PDO
Espocrm by the numbers
- 72 all-time installs (skills.sh)
- Ranked #3,076 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 1, 2026 (Skillselion catalog sync)
espocrm capabilities & compatibility
- Capabilities
- espocrm modules · crm customization · orm entitymanager · custom api actions
- Use cases
- api development · database
What espocrm says it does
EspoCRM is a metadata-driven CRM platform where configuration lives in JSON files, business logic belongs in Services, and data access happens through ORM EntityManager.
BUSINESS LOGIC IN SERVICES, NOT HOOKS | DATA ACCESS VIA ENTITYMANAGER, NEVER DIRECT PDO | NEVER PASS CONTAINER AS DEPENDENCY
EspoCRM provides 7 hook types - ALWAYS use interfaces:
npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill espocrmAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 72 |
|---|---|
| repo stars | ★ 63 |
| Last updated | July 18, 2026 |
| Repository | bobmatnyc/claude-mpm-skills ↗ |
What it does
Develop custom EspoCRM modules, entities, hooks, and API endpoints following its metadata-driven service-layer architecture.
Who is it for?
PHP developers extending EspoCRM with custom entities, services, hooks, custom field types, or API actions.
When should I use this skill?
When developing custom EspoCRM modules, entities, relationships, hooks, services, API endpoints, or integrations.
By the numbers
- EspoCRM provides 7 hook types documented in the skill
- Lists 11 reference documents (architecture, hooks-and-services, and more)
Files
EspoCRM Development
Overview
EspoCRM is a metadata-driven CRM platform where configuration lives in JSON files, business logic belongs in Services, and data access happens through ORM EntityManager. This skill enforces architectural patterns to prevent common mistakes like passing Container dependencies, bypassing the service layer, or implementing business logic in hooks.
When to Use This Skill
Activate when developing custom EspoCRM modules, entities, relationships, hooks, services, API endpoints, or integrations. Use especially when: working with ORM (EntityManager required), implementing business logic (belongs in Services), creating hooks (use interfaces), modifying metadata (requires cache rebuild), building custom field types, creating complex queries with SelectBuilder, implementing custom API actions, or packaging extensions.
The Iron Law
BUSINESS LOGIC IN SERVICES, NOT HOOKS | DATA ACCESS VIA ENTITYMANAGER, NEVER DIRECT PDO | NEVER PASS CONTAINER AS DEPENDENCY
Accessing Container directly or writing business logic in hooks violates architecture.
Core Architecture Principles
1. Metadata-Driven: Entity definitions, layouts, field configs live in JSON 2. Service Layer: All business logic implemented in Service classes 3. ORM EntityManager: Central access point for all database operations 4. Dependency Injection: Constructor injection, never pass Container 5. Hook System: Lifecycle events for validation and side effects (not business logic) 6. Repository Pattern: Entities accessed through repositories
Quick Start
1. Setup Development Environment - Use ext-template, work in src/ directory (EspoCRM 7.4+), understand metadata structure: custom/Espo/Modules/{ModuleName}/Resources/metadata/
2. Access Data with EntityManager
use Espo\ORM\EntityManager;
public function __construct(private EntityManager $entityManager) {}
// Find entity
$account = $this->entityManager->getEntityById('Account', $id);
// Query with conditions
$collection = $this->entityManager
->getRDBRepository('Contact')
->where(['accountId' => $accountId])
->find();3. Implement Business Logic in Services
namespace Espo\Modules\MyModule\Services;
use Espo\Services\Record;
class MyEntity extends Record {
public function customAction(string $id, object $data): object {
// Business logic here
$entity = $this->entityManager->getEntityById($this->entityType, $id);
// ... process ...
$this->entityManager->saveEntity($entity);
return $entity;
}
}4. Register Hooks for Lifecycle Events
namespace Espo\Modules\MyModule\Hooks\Account;
use Espo\ORM\Entity;
use Espo\Core\Hook\Hook\BeforeSave;
class MyHook implements BeforeSave {
public function beforeSave(Entity $entity, array $options): void {
// Validation or side effects only
if ($entity->isAttributeChanged('status')) {
// React to changes
}
}
}5. Rebuild Cache After Changes
bin/command rebuildHook Types (Interfaces)
EspoCRM provides 7 hook types - ALWAYS use interfaces: BeforeSave (validation before save), AfterSave (side effects after save), BeforeRemove (validation before delete), AfterRemove (cleanup after delete), AfterRelate (relationship creation), AfterUnrelate (relationship removal), AfterMassRelate (bulk relationship operations).
Navigation
Core Concepts
- [Architecture](references/architecture.md): Metadata system, ORM, DI container, repository pattern, and core architectural patterns
- [Development Workflow](references/development-workflow.md): Module creation, custom entities, fields, APIs, and extension development process
- [Hooks and Services](references/hooks-and-services.md): Service layer implementation, hook types, dependency injection, and business logic patterns
Advanced Topics
- [SelectBuilder](references/select-builder.md): Advanced querying with SelectBuilder - complex queries, joins, aggregations, and query optimization
- [API Actions](references/api-actions.md): Creating custom API endpoints - action handlers, request/response patterns, and authentication
- [Custom Field Types](references/custom-field-types.md): Building custom field types - backend, frontend, metadata, and integration
UI and Integration
- [Frontend Customization](references/frontend-customization.md): View system, client-side development, and UI customization
- [Common Tasks](references/common-tasks.md): Scheduled jobs, emails, PDFs, ACL, workflows, and integration patterns
- [Extension Packages](references/extension-packages.md): Packaging and distributing extensions - manifest files, installation, and versioning
Quality Assurance
- [Testing and Debugging](references/testing-debugging.md): Unit tests, debugging techniques, performance optimization, and common pitfalls
- [PHP Quality Anti-Patterns](references/php-quality-antipatterns.md): Language-level robustness/changeability defects — PHP4 constructor naming, uppercased control keywords,
goto, and emptycatchblocks, with compliant examples (derived from CAST Highlight code quality indicators, https://doc.casthighlight.com/)
Key Patterns
Correct Pattern:
✅ Service with injected dependencies
✅ EntityManager for data access
✅ Hooks using interfaces
✅ Type declarations on all methods
✅ Exceptions for error handlingIncorrect Patterns:
❌ Passing Container as dependency
❌ Direct PDO database access
❌ Business logic in hooks
❌ Hook base classes instead of interfaces
❌ Missing type declarationsCommon Mistakes to Avoid
- Never pass Container - Inject specific dependencies instead
- Don't bypass EntityManager - Use ORM, not raw queries
- Business logic doesn't belong in hooks - Use Services
- Always rebuild cache - After metadata changes (
bin/command rebuild) - Use interfaces for hooks - Not base classes
- Type everything - PHP 7.4+ requires type declarations
- Throw exceptions - Don't return booleans for errors
Integration with Other Skills
- systematic-debugging: Debug EspoCRM issues using logs and step debugging
- verification-before-completion: Always test with cache rebuild before claiming complete
- test-driven-development: Write unit tests for Services and hooks
The Bottom Line
EspoCRM is metadata-driven with a service layer architecture.
Understand the metadata system. Use EntityManager for data. Implement business logic in Services. Use hooks for lifecycle events only. Rebuild cache after changes.
This is the EspoCRM way.
{
"name": "espocrm",
"version": "1.2.0",
"category": "toolchain",
"toolchain": "php",
"framework": "espocrm",
"tags": [
"performance",
"api",
"security",
"testing",
"debugging",
"select-builder",
"api-actions",
"custom-fields",
"extension-packages",
"query-optimization"
],
"entry_point_tokens": 550,
"full_tokens": 45000,
"author": "bobmatnyc",
"license": "MIT",
"requires": [],
"updated": "2026-06-15",
"source_path": "php/espocrm-development/SKILL.md",
"source": "https://github.com/bobmatnyc/claude-mpm",
"created": "2025-11-21",
"modified": "2026-06-15",
"maintainer": "Claude MPM Team",
"attribution_required": true,
"repository": "https://github.com/bobmatnyc/claude-mpm-skills"
}
API Actions & Custom Endpoints Reference
Overview
EspoCRM provides two approaches for creating custom API endpoints: 1. Action Classes (Recommended) - Modern, single-responsibility classes 2. Controllers (Legacy) - Multiple actions in one class
API Structure
- Root Path:
api/v1/ - Format: REST API returning JSON
- Content-Type:
application/jsonfor POST/PUT with JSON payloads - Authentication: API Key, HMAC, or Basic Auth
Route Definition
Routes are defined in Resources/routes.json:
[
{
"route": "/MyEntity/:id/customAction",
"method": "post",
"params": {
"controller": "MyEntity",
"action": "customAction"
}
},
{
"route": "/MyEntity/bulkProcess",
"method": "post",
"actionClassName": "Espo\\Modules\\MyModule\\Api\\BulkProcess"
},
{
"route": "/Reports/:reportId/generate",
"method": "get",
"actionClassName": "Espo\\Modules\\MyModule\\Api\\GenerateReport"
},
{
"route": "/Integration/sync",
"method": "post",
"actionClassName": "Espo\\Modules\\MyModule\\Api\\SyncData",
"noAuth": true
}
]Route Parameters
| Parameter | Description |
|---|---|
route | URL pattern with :param placeholders |
method | HTTP method (get, post, put, patch, delete) |
actionClassName | Full class name for Action approach |
controller + action | Controller name and method for legacy approach |
noAuth | Allow unauthenticated access (webhooks, public endpoints) |
Action Classes (Recommended)
Basic Action
<?php
namespace Espo\Modules\MyModule\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use Espo\Core\Exceptions\NotFound;
use Espo\ORM\EntityManager;
use Espo\Core\Acl;
class CustomAction implements Action
{
public function __construct(
private EntityManager $entityManager,
private Acl $acl
) {}
public function process(Request $request): Response
{
// Get route parameter
$id = $request->getRouteParam('id');
if (!$id) {
throw new BadRequest('ID is required');
}
// Get entity
$entity = $this->entityManager->getEntityById('MyEntity', $id);
if (!$entity) {
throw new NotFound();
}
// Check access
if (!$this->acl->checkEntityRead($entity)) {
throw new Forbidden();
}
// Process and return
return ResponseComposer::json([
'id' => $entity->getId(),
'name' => $entity->get('name'),
'status' => $entity->get('status')
]);
}
}Action with POST Body
<?php
namespace Espo\Modules\MyModule\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\ORM\EntityManager;
use stdClass;
class CreateWithValidation implements Action
{
public function __construct(
private EntityManager $entityManager
) {}
public function process(Request $request): Response
{
// Get JSON body
$body = $request->getParsedBody();
// Validate required fields
$this->validate($body);
// Create entity
$entity = $this->entityManager->createEntity('MyEntity', [
'name' => $body->name,
'description' => $body->description ?? null,
'priority' => $body->priority ?? 'Normal'
]);
// Return created entity
return ResponseComposer::json([
'id' => $entity->getId(),
'name' => $entity->get('name')
]);
}
private function validate(stdClass $body): void
{
if (empty($body->name)) {
throw new BadRequest('Name is required');
}
if (strlen($body->name) > 255) {
throw new BadRequest('Name must be 255 characters or less');
}
$allowedPriorities = ['Low', 'Normal', 'High', 'Urgent'];
if (!empty($body->priority) && !in_array($body->priority, $allowedPriorities)) {
throw new BadRequest('Invalid priority value');
}
}
}Action with Query Parameters
<?php
namespace Espo\Modules\MyModule\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\ORM\EntityManager;
class SearchWithFilters implements Action
{
public function __construct(
private EntityManager $entityManager
) {}
public function process(Request $request): Response
{
// Get query parameters
$status = $request->getQueryParam('status');
$offset = (int) ($request->getQueryParam('offset') ?? 0);
$limit = min((int) ($request->getQueryParam('limit') ?? 20), 100);
$orderBy = $request->getQueryParam('orderBy') ?? 'createdAt';
$order = $request->getQueryParam('order') ?? 'desc';
// Build query
$repository = $this->entityManager->getRDBRepository('MyEntity');
if ($status) {
$repository->where(['status' => $status]);
}
$collection = $repository
->order($orderBy, strtoupper($order))
->limit($offset, $limit)
->find();
$total = $repository->count();
// Format response
$list = [];
foreach ($collection as $entity) {
$list[] = [
'id' => $entity->getId(),
'name' => $entity->get('name'),
'status' => $entity->get('status'),
'createdAt' => $entity->get('createdAt')
];
}
return ResponseComposer::json([
'total' => $total,
'list' => $list
]);
}
}Action with File Upload
<?php
namespace Espo\Modules\MyModule\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\FileStorage\Manager as FileStorageManager;
use Espo\ORM\EntityManager;
use Psr\Http\Message\UploadedFileInterface;
class UploadAttachment implements Action
{
public function __construct(
private EntityManager $entityManager,
private FileStorageManager $fileStorageManager
) {}
public function process(Request $request): Response
{
$uploadedFiles = $request->getUploadedFiles();
if (empty($uploadedFiles['file'])) {
throw new BadRequest('No file uploaded');
}
/** @var UploadedFileInterface $uploadedFile */
$uploadedFile = $uploadedFiles['file'];
// Validate file
$allowedTypes = ['image/jpeg', 'image/png', 'application/pdf'];
$mimeType = $uploadedFile->getClientMediaType();
if (!in_array($mimeType, $allowedTypes)) {
throw new BadRequest('Invalid file type');
}
// Create attachment entity
$attachment = $this->entityManager->createEntity('Attachment', [
'name' => $uploadedFile->getClientFilename(),
'type' => $mimeType,
'size' => $uploadedFile->getSize()
]);
// Store file
$stream = $uploadedFile->getStream();
$this->fileStorageManager->putStream($attachment, $stream);
return ResponseComposer::json([
'id' => $attachment->getId(),
'name' => $attachment->get('name')
]);
}
}Action with Custom Response
<?php
namespace Espo\Modules\MyModule\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
class DownloadFile implements Action
{
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
// Get file content
$content = $this->getFileContent($id);
$filename = $this->getFilename($id);
$response = new Response();
$response
->setStatus(200)
->setHeader('Content-Type', 'application/octet-stream')
->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"')
->setHeader('Content-Length', (string) strlen($content))
->writeBody($content);
return $response;
}
private function getFileContent(string $id): string
{
// Implementation
return '';
}
private function getFilename(string $id): string
{
return 'file.pdf';
}
}Controller Approach (Legacy)
For multiple related actions, use a controller:
<?php
namespace Espo\Modules\MyModule\Controllers;
use Espo\Core\Controllers\RecordBase;
use Espo\Core\Api\Request;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
class MyEntity extends RecordBase
{
// POST MyEntity/:id/markComplete
public function postActionMarkComplete(Request $request): object
{
$id = $request->getRouteParam('id');
$data = $request->getParsedBody();
if (!$id) {
throw new BadRequest('ID is required');
}
$service = $this->getRecordService();
$entity = $service->markComplete($id, $data);
return $entity->getValueMap();
}
// GET MyEntity/:id/summary
public function getActionSummary(Request $request): object
{
$id = $request->getRouteParam('id');
$service = $this->getRecordService();
return $service->getSummary($id);
}
// PUT MyEntity/:id/reassign
public function putActionReassign(Request $request): object
{
$id = $request->getRouteParam('id');
$data = $request->getParsedBody();
if (empty($data->assignedUserId)) {
throw new BadRequest('assignedUserId is required');
}
$service = $this->getRecordService();
$entity = $service->reassign($id, $data->assignedUserId);
return $entity->getValueMap();
}
// DELETE MyEntity/:id/attachment/:attachmentId
public function deleteActionRemoveAttachment(Request $request): bool
{
$id = $request->getRouteParam('id');
$attachmentId = $request->getRouteParam('attachmentId');
$service = $this->getRecordService();
$service->removeAttachment($id, $attachmentId);
return true;
}
}Controller Route Configuration
[
{
"route": "/MyEntity/:id/markComplete",
"method": "post",
"params": {
"controller": "MyEntity",
"action": "markComplete"
}
},
{
"route": "/MyEntity/:id/summary",
"method": "get",
"params": {
"controller": "MyEntity",
"action": "summary"
}
},
{
"route": "/MyEntity/:id/reassign",
"method": "put",
"params": {
"controller": "MyEntity",
"action": "reassign"
}
},
{
"route": "/MyEntity/:id/attachment/:attachmentId",
"method": "delete",
"params": {
"controller": "MyEntity",
"action": "removeAttachment"
}
}
]Authentication
API Key Authentication
curl -X GET "https://your-espo/api/v1/Account" \
-H "X-Api-Key: YOUR_API_KEY"HMAC Authentication (Most Secure)
// Generate HMAC signature
$method = 'GET';
$uri = '/api/v1/Account';
$secretKey = 'your-secret-key'; // pragma: allowlist secret
$apiKey = 'your-api-key'; // pragma: allowlist secret
$string = $method . ' ' . $uri;
$signature = base64_encode(hash_hmac('sha256', $string, $secretKey, true));
$authHeader = base64_encode($apiKey . ':' . $signature);curl -X GET "https://your-espo/api/v1/Account" \
-H "X-Hmac-Authorization: $AUTH_HEADER"Webhook (No Auth)
For webhooks from external services:
{
"route": "/Webhook/stripe",
"method": "post",
"actionClassName": "Espo\\Modules\\Payment\\Api\\StripeWebhook",
"noAuth": true
}<?php
namespace Espo\Modules\Payment\Api;
use Espo\Core\Api\Action;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Api\ResponseComposer;
use Espo\Core\Exceptions\BadRequest;
class StripeWebhook implements Action
{
public function process(Request $request): Response
{
// Verify webhook signature
$signature = $request->getHeader('Stripe-Signature');
$payload = $request->getBodyContents();
if (!$this->verifySignature($payload, $signature)) {
throw new BadRequest('Invalid signature');
}
// Process webhook
$event = json_decode($payload);
$this->handleEvent($event);
return ResponseComposer::json(['received' => true]);
}
private function verifySignature(string $payload, ?string $signature): bool
{
// Stripe signature verification
return true;
}
private function handleEvent(object $event): void
{
// Handle different event types
}
}Error Handling
Standard Exceptions
use Espo\Core\Exceptions\BadRequest; // 400
use Espo\Core\Exceptions\Forbidden; // 403
use Espo\Core\Exceptions\NotFound; // 404
use Espo\Core\Exceptions\Conflict; // 409
use Espo\Core\Exceptions\Error; // 500
// Usage
throw new BadRequest('Invalid input data');
throw new Forbidden('Access denied');
throw new NotFound('Record not found');
throw new Conflict('Record has been modified');
throw new Error('Internal server error');Custom Error Response
public function process(Request $request): Response
{
try {
// Processing logic
} catch (\Exception $e) {
$response = new Response();
$response->setStatus(422);
$response->writeBody(json_encode([
'error' => 'Validation failed',
'details' => $e->getMessage()
]));
return $response;
}
}Middleware and Access Control
Check Entity Access
public function process(Request $request): Response
{
$id = $request->getRouteParam('id');
$entity = $this->entityManager->getEntityById('MyEntity', $id);
// Read access
if (!$this->acl->checkEntityRead($entity)) {
throw new Forbidden();
}
// Edit access
if (!$this->acl->checkEntityEdit($entity)) {
throw new Forbidden();
}
// Delete access
if (!$this->acl->checkEntityDelete($entity)) {
throw new Forbidden();
}
// Create access (scope level)
if (!$this->acl->checkScope('MyEntity', 'create')) {
throw new Forbidden();
}
}OpenAPI Documentation
EspoCRM auto-generates OpenAPI spec at /api/v1/OpenAPI:
curl -X GET "https://your-espo/api/v1/OpenAPI" \
-H "X-Api-Key: YOUR_API_KEY"Best Practices
DO:
- Use Action classes for new endpoints (modern approach)
- Always validate input data
- Check ACL permissions before operations
- Use appropriate HTTP methods (GET for read, POST for create, etc.)
- Return meaningful error messages
- Log errors for debugging
- Clear cache after adding routes:
bin/command rebuild
DON'T:
- Skip authentication on sensitive endpoints
- Return stack traces in production
- Use GET for operations that modify data
- Bypass ACL checks
- Hardcode user IDs or entity types
- Forget to handle edge cases (null, empty, etc.)
Resources
EspoCRM Architecture Reference
Metadata-Driven Architecture
EspoCRM's core architecture is built on metadata - JSON configuration files that define entities, fields, relationships, and behaviors.
Metadata Structure
Metadata lives in several locations with a priority order:
1. custom/Espo/Custom/Resources/metadata/ (highest priority)
2. custom/Espo/Modules/{ModuleName}/Resources/metadata/
3. application/Espo/Modules/{ModuleName}/Resources/metadata/
4. application/Espo/Resources/metadata/ (lowest priority)Key Metadata Types
Entity Definitions (entityDefs/{EntityType}.json):
{
"fields": {
"name": {
"type": "varchar",
"required": true,
"maxLength": 255
},
"status": {
"type": "enum",
"options": ["New", "In Progress", "Complete"],
"default": "New"
}
},
"links": {
"account": {
"type": "belongsTo",
"entity": "Account",
"foreign": "contacts"
}
}
}Client Definitions (clientDefs/{EntityType}.json):
{
"controller": "custom:controllers/my-entity",
"views": {
"detail": "custom:views/my-entity/detail"
},
"recordViews": {
"detail": "custom:views/my-entity/record/detail"
},
"sidePanels": {
"detail": [
{
"name": "activities",
"label": "Activities",
"view": "crm:views/record/panels/activities"
}
]
}
}Scopes (scopes/{EntityType}.json):
{
"entity": true,
"object": true,
"layouts": true,
"tab": true,
"acl": true,
"module": "MyModule",
"stream": true
}Metadata Access in Code
use Espo\Core\Utils\Metadata;
class MyService {
public function __construct(private Metadata $metadata) {}
public function getEntityFields(string $entityType): array {
return $this->metadata->get(['entityDefs', $entityType, 'fields']) ?? [];
}
public function isFieldRequired(string $entityType, string $field): bool {
return $this->metadata
->get(['entityDefs', $entityType, 'fields', $field, 'required']) ?? false;
}
}ORM EntityManager
EntityManager is the central access point for ALL database operations in EspoCRM.
Core EntityManager Methods
use Espo\ORM\EntityManager;
class DataService {
public function __construct(private EntityManager $entityManager) {}
// Get entity by ID
public function getById(string $entityType, string $id): ?Entity {
return $this->entityManager->getEntityById($entityType, $id);
}
// Create new entity
public function create(string $entityType): Entity {
return $this->entityManager->getNewEntity($entityType);
}
// Save entity
public function save(Entity $entity): void {
$this->entityManager->saveEntity($entity);
}
// Delete entity
public function delete(Entity $entity): void {
$this->entityManager->removeEntity($entity);
}
// Get repository
public function getRepository(string $entityType): RDBRepository {
return $this->entityManager->getRDBRepository($entityType);
}
}Repository Pattern
Never access repositories directly - always through EntityManager:
// Query with conditions
$contacts = $this->entityManager
->getRDBRepository('Contact')
->where([
'accountId' => $accountId,
'deleted' => false
])
->find();
// Complex queries
$query = $this->entityManager
->getQueryBuilder()
->select()
->from('Opportunity')
->where([
'stage' => ['Proposal', 'Negotiation'],
'amount>=' => 10000
])
->order('createdAt', 'DESC')
->build();
$collection = $this->entityManager
->getRDBRepository('Opportunity')
->clone($query)
->find();Transaction Handling
use Espo\ORM\TransactionManager;
class TransactionalService {
public function __construct(
private EntityManager $entityManager,
private TransactionManager $transactionManager
) {}
public function performComplexOperation(): void {
$this->transactionManager->run(function () {
// All operations within this closure are transactional
$entity1 = $this->entityManager->getNewEntity('Account');
$entity1->set('name', 'Test');
$this->entityManager->saveEntity($entity1);
$entity2 = $this->entityManager->getNewEntity('Contact');
$entity2->set('accountId', $entity1->getId());
$this->entityManager->saveEntity($entity2);
// If any exception is thrown, all changes are rolled back
});
}
}STH Collections for Large Datasets
For operations on large datasets, use STH (Statement Handle) collections to avoid memory issues:
$sthCollection = $this->entityManager
->getRDBRepository('Contact')
->sth() // Returns STH collection instead of loading all into memory
->where(['accountId' => $accountId])
->find();
foreach ($sthCollection as $contact) {
// Process one at a time
$contact->set('status', 'Active');
$this->entityManager->saveEntity($contact);
}Dependency Injection Container
EspoCRM uses a DI container for dependency management.
Constructor Injection (CORRECT)
namespace Espo\Modules\MyModule\Services;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Metadata;
use Espo\Core\Mail\EmailSender;
class MyService {
public function __construct(
private EntityManager $entityManager,
private Metadata $metadata,
private EmailSender $emailSender
) {}
}NEVER Pass Container
// ❌ WRONG - Never do this
use Espo\Core\Container;
class BadService {
public function __construct(private Container $container) {}
}
// ✅ CORRECT - Inject specific dependencies
class GoodService {
public function __construct(
private EntityManager $entityManager,
private Metadata $metadata
) {}
}Injectable Services
Common services available for injection:
EntityManager- ORM accessMetadata- Metadata accessConfig- Application configurationFileStorageManager- File operationsInjectableFactory- Create objects with DIServiceFactory- Access record servicesEmailSender- Send emailsAcl- Access controlUser- Current userDateTime- Date/time utilitiesLanguage- TranslationsTransactionManager- Database transactions
Service Layer Architecture
Business logic belongs in Service classes, not hooks or controllers.
Service Hierarchy
Record Service (base for all entity services)
↓
Custom Service (your entity-specific logic)Extending Record Service
namespace Espo\Modules\MyModule\Services;
use Espo\Services\Record;
use Espo\ORM\Entity;
class Opportunity extends Record {
// Override to add custom logic before create
protected function beforeCreateEntity(Entity $entity, array $data): void {
parent::beforeCreateEntity($entity, $data);
// Custom logic
if ($entity->get('amount') > 100000) {
$entity->set('priority', 'High');
}
}
// Custom action
public function markAsWon(string $id): Entity {
$entity = $this->getEntity($id);
if (!$entity) {
throw new NotFound();
}
$entity->set('stage', 'Closed Won');
$this->entityManager->saveEntity($entity);
// Trigger additional business logic
$this->createWinNotification($entity);
return $entity;
}
private function createWinNotification(Entity $opportunity): void {
// Implementation
}
}Service Access
use Espo\Core\ServiceFactory;
class MyClass {
public function __construct(private ServiceFactory $serviceFactory) {}
public function useService(): void {
$opportunityService = $this->serviceFactory->create('Opportunity');
$opportunityService->markAsWon($id);
}
}Hook System Architecture
Hooks are for lifecycle events - validation and side effects ONLY. Business logic belongs in Services.
The 7 Hook Interfaces
namespace Espo\Core\Hook\Hook;
interface BeforeSave {
public function beforeSave(Entity $entity, array $options): void;
}
interface AfterSave {
public function afterSave(Entity $entity, array $options): void;
}
interface BeforeRemove {
public function beforeRemove(Entity $entity, array $options): void;
}
interface AfterRemove {
public function afterRemove(Entity $entity, array $options): void;
}
interface AfterRelate {
public function afterRelate(Entity $entity, string $relationName, Entity $foreign, ?array $columnData, array $options): void;
}
interface AfterUnrelate {
public function afterUnrelate(Entity $entity, string $relationName, Entity $foreign, array $options): void;
}
interface AfterMassRelate {
public function afterMassRelate(Entity $entity, string $relationName, array $params, array $options): void;
}Hook Implementation Example
namespace Espo\Modules\MyModule\Hooks\Account;
use Espo\ORM\Entity;
use Espo\Core\Hook\Hook\BeforeSave;
use Espo\Core\ServiceFactory;
class ValidateWebsite implements BeforeSave {
public function __construct(private ServiceFactory $serviceFactory) {}
public function beforeSave(Entity $entity, array $options): void {
// Validation only
if ($entity->isAttributeChanged('website')) {
$website = $entity->get('website');
if ($website && !filter_var($website, FILTER_VALIDATE_URL)) {
throw new \Espo\Core\Exceptions\BadRequest('Invalid website URL');
}
}
}
}Hook Registration
Hooks are auto-discovered in:
custom/Espo/Modules/{ModuleName}/Hooks/{EntityType}/{HookName}.phpCoding Standards
Type Declarations (Required)
// ✅ CORRECT - All types declared
class MyService {
public function processData(string $id, array $data): object {
return $this->entityManager->getEntityById('Account', $id);
}
}
// ❌ WRONG - Missing types
class BadService {
public function processData($id, $data) {
return $this->entityManager->getEntityById('Account', $id);
}
}Exception Handling (Not Booleans)
// ✅ CORRECT - Use exceptions
use Espo\Core\Exceptions\{NotFound, Forbidden, BadRequest};
public function getAccount(string $id): Entity {
$account = $this->entityManager->getEntityById('Account', $id);
if (!$account) {
throw new NotFound();
}
if (!$this->acl->check($account, 'read')) {
throw new Forbidden();
}
return $account;
}
// ❌ WRONG - Returning booleans for errors
public function getAccount(string $id): ?Entity {
$account = $this->entityManager->getEntityById('Account', $id);
if (!$account) {
return null; // Lost error context
}
return $account;
}Composition Over Inheritance
// ✅ CORRECT - Composition with traits/utilities
class MyService extends Record {
use ValidationTrait;
public function __construct(
private ValidationHelper $validationHelper,
private NotificationHelper $notificationHelper
) {
parent::__construct();
}
}
// ❌ WRONG - Deep inheritance hierarchy
class MyService extends IntermediateService extends BaseService extends Record {
// Too many levels
}DTOs Over Arrays
// ✅ CORRECT - Use DTOs
class CreateAccountData {
public function __construct(
public readonly string $name,
public readonly ?string $website,
public readonly array $tags
) {}
}
public function createAccount(CreateAccountData $data): Entity {
// Type-safe operations
}
// ❌ WRONG - Untyped arrays
public function createAccount(array $data): Entity {
$name = $data['name'] ?? ''; // Fragile, no IDE support
}Maximum 2 Indentation Levels
// ✅ CORRECT - Early returns, extracted methods
public function process(Entity $entity): void {
if (!$this->validate($entity)) {
return;
}
$this->performUpdate($entity);
}
private function performUpdate(Entity $entity): void {
if ($entity->isNew()) {
$this->handleNew($entity);
return;
}
$this->handleExisting($entity);
}
// ❌ WRONG - Deep nesting
public function process(Entity $entity): void {
if ($this->validate($entity)) {
if ($entity->isNew()) {
if ($this->hasPermission()) {
// Three levels deep - hard to read
}
}
}
}Formula Scripting
EspoCRM supports declarative logic through Formula scripts - use for simple field calculations instead of hooks.
Formula in Metadata
{
"fields": {
"totalPrice": {
"type": "currency",
"formula": "quantity * unitPrice"
},
"displayName": {
"type": "varchar",
"formula": "string\\concatenate(firstName, ' ', lastName)"
}
}
}When to Use Formula vs. Hooks
Use Formula for:
- Simple field calculations
- String concatenation
- Conditional field values
- Date calculations
Use Hooks/Services for:
- Complex business logic
- External API calls
- Multi-entity operations
- Validation requiring database queries
Cache Management
Rebuild Cache After Metadata Changes
# Always run after changing metadata
bin/command rebuildClear Cache Programmatically
use Espo\Core\Utils\DataCache;
class MyService {
public function __construct(private DataCache $dataCache) {}
public function clearCache(): void {
$this->dataCache->clear();
}
}Cache Keys
Common cache keys to be aware of:
metadata- All metadataentityDefs- Entity definitionsclientDefs- Client definitionsaclDefs- ACL definitions
Common Tasks Reference
Scheduled Jobs
Scheduled jobs run automatically at specified intervals via cron.
Creating a Scheduled Job
Step 1: Define Job in Metadata
Create src/files/custom/Espo/Modules/MyModule/Resources/metadata/app/scheduledJobs.json:
{
"MyCustomJob": {
"name": "My Custom Job",
"scheduling": "*/30 * * * *"
},
"DataSyncJob": {
"name": "Sync External Data",
"scheduling": "0 2 * * *"
}
}Scheduling format (cron syntax):
* * * * *
│ │ │ │ │
│ │ │ │ └─── Day of week (0-6, Sunday = 0)
│ │ │ └───── Month (1-12)
│ │ └─────── Day of month (1-31)
│ └───────── Hour (0-23)
└─────────── Minute (0-59)Common schedules:
*/5 * * * *- Every 5 minutes0 * * * *- Every hour0 2 * * *- Daily at 2 AM0 0 * * 0- Weekly on Sunday at midnight
Step 2: Implement Job Class
Create src/files/custom/Espo/Modules/MyModule/Jobs/MyCustomJob.php:
<?php
namespace Espo\Modules\MyModule\Jobs;
use Espo\Core\Job\JobDataLess;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Log;
class MyCustomJob implements JobDataLess
{
public function __construct(
private EntityManager $entityManager,
private Log $log
) {}
public function run(): void
{
$this->log->info('MyCustomJob started');
try {
// Job logic
$this->processRecords();
$this->log->info('MyCustomJob completed successfully');
} catch (\Throwable $e) {
$this->log->error('MyCustomJob failed: ' . $e->getMessage());
throw $e;
}
}
private function processRecords(): void
{
$accounts = $this->entityManager
->getRDBRepository('Account')
->where([
'status' => 'Active',
'lastContactedAt<' => date('Y-m-d', strtotime('-30 days'))
])
->find();
foreach ($accounts as $account) {
$account->set('needsFollowUp', true);
$this->entityManager->saveEntity($account);
}
$this->log->info('Processed ' . count($accounts) . ' accounts');
}
}Advanced: Job with Data
For jobs that need parameters:
<?php
namespace Espo\Modules\MyModule\Jobs;
use Espo\Core\Job\Job;
use Espo\Core\Job\Job\Data;
class DataSyncJob implements Job
{
public function __construct(
private EntityManager $entityManager,
private Log $log
) {}
public function run(Data $data): void
{
$entityType = $data->get('entityType');
$limit = $data->get('limit') ?? 100;
$this->syncData($entityType, $limit);
}
private function syncData(string $entityType, int $limit): void
{
// Implementation
}
}Running Jobs Manually
// Via service
use Espo\Core\Job\JobSchedulerFactory;
class MyService {
public function __construct(
private JobSchedulerFactory $jobSchedulerFactory
) {}
public function triggerJob(): void
{
$jobScheduler = $this->jobSchedulerFactory->create();
$jobScheduler->scheduleJob('MyCustomJob', [
'entityType' => 'Account',
'limit' => 50
]);
}
}Email Management
Sending Emails
<?php
namespace Espo\Modules\MyModule\Services;
use Espo\Core\Mail\EmailSender;
use Espo\Entities\Email;
use Espo\ORM\EntityManager;
class NotificationService
{
public function __construct(
private EmailSender $emailSender,
private EntityManager $entityManager
) {}
public function sendWelcomeEmail(string $contactId): void
{
$contact = $this->entityManager->getEntityById('Contact', $contactId);
if (!$contact) {
return;
}
$emailAddress = $contact->get('emailAddress');
if (!$emailAddress) {
return;
}
$sender = $this->emailSender->create();
$sender
->withSubject('Welcome to Our Platform')
->withBody('Dear ' . $contact->get('name') . ',\n\nWelcome!')
->withTo($emailAddress)
->send();
}
public function sendEmailWithTemplate(string $contactId): void
{
$contact = $this->entityManager->getEntityById('Contact', $contactId);
if (!$contact) {
return;
}
// Create email entity
$email = $this->entityManager->getNewEntity('Email');
$email->set([
'to' => $contact->get('emailAddress'),
'subject' => 'Welcome',
'body' => $this->renderTemplate($contact),
'isHtml' => true,
'parentType' => 'Contact',
'parentId' => $contactId
]);
$this->entityManager->saveEntity($email);
// Send
$sender = $this->emailSender->create();
$sender
->withEnvelopeOptions([
'from' => 'noreply@example.com'
])
->send($email);
}
private function renderTemplate($contact): string
{
return '<h1>Welcome ' . htmlspecialchars($contact->get('name')) . '</h1>';
}
}Email Templates
Create src/files/custom/Espo/Modules/MyModule/Resources/metadata/app/emailTemplates.json:
{
"welcomeEmail": {
"subject": "Welcome {Contact.name}",
"body": "<p>Dear {Contact.name},</p><p>Welcome to our platform!</p>"
}
}Using email templates:
use Espo\Tools\EmailTemplate\Processor;
class EmailService {
public function __construct(
private Processor $emailTemplateProcessor,
private EmailSender $emailSender,
private EntityManager $entityManager
) {}
public function sendFromTemplate(string $templateId, string $entityId): void
{
$template = $this->entityManager->getEntityById('EmailTemplate', $templateId);
$entity = $this->entityManager->getEntityById('Contact', $entityId);
// Process template (replace placeholders)
$data = $this->emailTemplateProcessor->process($template, [
'entityType' => 'Contact',
'entity' => $entity
]);
// Send
$sender = $this->emailSender->create();
$sender
->withSubject($data->getSubject())
->withBody($data->getBody())
->withTo($entity->get('emailAddress'))
->withIsHtml($data->isHtml())
->send();
}
}PDF Generation
Creating PDF Templates
PDF templates use HTML with placeholders.
Create custom/Espo/Custom/Resources/templates/Invoice.html:
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; }
.header { text-align: center; margin-bottom: 30px; }
.invoice-details { margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
.total { text-align: right; font-weight: bold; }
</style>
</head>
<body>
<div class="header">
<h1>INVOICE</h1>
<p>Invoice #{{number}}</p>
</div>
<div class="invoice-details">
<p><strong>Date:</strong> {{dateInvoiced}}</p>
<p><strong>Customer:</strong> {{account.name}}</p>
<p><strong>Amount:</strong> {{amount}}</p>
</div>
<table>
<thead>
<tr>
<th>Item</th>
<th>Quantity</th>
<th>Price</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{{#each items}}
<tr>
<td>{{name}}</td>
<td>{{quantity}}</td>
<td>{{price}}</td>
<td>{{total}}</td>
</tr>
{{/each}}
</tbody>
</table>
<div class="total">
<p>Total: {{amount}}</p>
</div>
</body>
</html>Generating PDFs Programmatically
<?php
namespace Espo\Modules\MyModule\Services;
use Espo\Tools\Pdf\Service as PdfService;
use Espo\ORM\EntityManager;
class InvoiceService
{
public function __construct(
private PdfService $pdfService,
private EntityManager $entityManager
) {}
public function generateInvoicePdf(string $invoiceId): string
{
$invoice = $this->entityManager->getEntityById('Invoice', $invoiceId);
if (!$invoice) {
throw new \Espo\Core\Exceptions\NotFound();
}
// Generate PDF
$contents = $this->pdfService->generate(
'Invoice', // Entity type
$invoiceId, // Entity ID
'Invoice' // Template name
);
// Save to file
$fileName = 'invoice_' . $invoice->get('number') . '.pdf';
$filePath = 'data/upload/' . $fileName;
file_put_contents($filePath, $contents);
return $filePath;
}
}Access Control (ACL)
Implementing Custom ACL
Create src/files/custom/Espo/Modules/MyModule/Acl/MyEntity.php:
<?php
namespace Espo\Modules\MyModule\Acl;
use Espo\ORM\Entity;
use Espo\Core\Acl\Table;
use Espo\Entities\User;
use Espo\Core\Acl\AccessEntityCREDChecker;
use Espo\Core\Acl\DefaultAccessChecker;
use Espo\Core\Acl\ScopeData;
use Espo\Core\Acl\Traits\DefaultAccessCheckerDependency;
class MyEntity implements AccessEntityCREDChecker
{
use DefaultAccessCheckerDependency;
public function __construct(
private DefaultAccessChecker $defaultAccessChecker
) {}
public function checkEntityRead(User $user, Entity $entity, ScopeData $data): bool
{
// Custom read permission logic
// Check if user is assigned
if ($entity->get('assignedUserId') === $user->getId()) {
return true;
}
// Check if user is in account team
if ($entity->get('accountId')) {
$account = $entity->get('account');
if ($this->isUserInAccountTeams($user, $account)) {
return true;
}
}
// Fall back to default ACL check
return $this->defaultAccessChecker->checkEntityRead($user, $entity, $data);
}
public function checkEntityCreate(User $user, Entity $entity, ScopeData $data): bool
{
// Custom create permission logic
if ($user->get('type') === 'portal') {
// Portal users can only create if they have an account
return $entity->get('accountId') !== null;
}
return $this->defaultAccessChecker->checkEntityCreate($user, $entity, $data);
}
public function checkEntityEdit(User $user, Entity $entity, ScopeData $data): bool
{
// Custom edit permission logic
// Only owner can edit after 7 days
$createdAt = $entity->get('createdAt');
if ($createdAt) {
$daysSinceCreation = (time() - strtotime($createdAt)) / 86400;
if ($daysSinceCreation > 7) {
if ($entity->get('createdById') !== $user->getId()) {
return false;
}
}
}
return $this->defaultAccessChecker->checkEntityEdit($user, $entity, $data);
}
public function checkEntityDelete(User $user, Entity $entity, ScopeData $data): bool
{
// Custom delete permission logic
// Prevent deletion of completed items
if ($entity->get('status') === 'Complete') {
return false;
}
return $this->defaultAccessChecker->checkEntityDelete($user, $entity, $data);
}
private function isUserInAccountTeams(User $user, ?Entity $account): bool
{
if (!$account) {
return false;
}
$userTeamIds = array_column($user->get('teams')->toArray(), 'id');
$accountTeamIds = array_column($account->get('teams')->toArray(), 'id');
return !empty(array_intersect($userTeamIds, $accountTeamIds));
}
}Checking ACL in Code
// Check entity-level permission
if (!$this->acl->check($entity, 'read')) {
throw new Forbidden();
}
// Check scope-level permission
if (!$this->acl->check('Account', 'create')) {
throw new Forbidden();
}
// Check field-level permission
if (!$this->acl->checkField('Account', 'billingAddress', 'edit')) {
throw new Forbidden('Cannot edit billing address');
}
// Check ownership level
$level = $this->acl->getLevel('Account', 'read');
// Levels: all, team, own, no
// Filter query by ACL
$query = $this->entityManager
->getQueryBuilder()
->select()
->from('Account')
->build();
$this->acl->applyFilter($query, 'Account', 'read');Workflow Customization
Custom Workflow Action
Create src/files/custom/Espo/Modules/MyModule/Classes/Workflow/Actions/SendSlackNotification.php:
<?php
namespace Espo\Modules\MyModule\Classes\Workflow\Actions;
use Espo\Core\Workflow\Action;
use Espo\Core\Workflow\Action\Params;
use Espo\ORM\Entity;
class SendSlackNotification implements Action
{
public function __construct(
private SlackClient $slackClient
) {}
public function run(Entity $entity, Params $params): bool
{
$channel = $params->get('channel') ?? '#general';
$message = $params->get('message') ?? 'Entity updated';
// Replace placeholders
$message = str_replace('{name}', $entity->get('name'), $message);
$this->slackClient->sendMessage($channel, $message);
return true;
}
}Custom Workflow Condition
Create src/files/custom/Espo/Modules/MyModule/Classes/Workflow/Conditions/IsHighValue.php:
<?php
namespace Espo\Modules\MyModule\Classes\Workflow\Conditions;
use Espo\Core\Workflow\Condition;
use Espo\Core\Workflow\Condition\Params;
use Espo\ORM\Entity;
class IsHighValue implements Condition
{
public function check(Entity $entity, Params $params): bool
{
$threshold = $params->get('threshold') ?? 10000;
$amount = $entity->get('amount') ?? 0;
return $amount >= $threshold;
}
}Integration Patterns
REST API Integration
<?php
namespace Espo\Modules\MyModule\Services;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Log;
class ExternalApiService
{
private string $apiUrl;
private string $apiKey;
public function __construct(
private Config $config,
private Log $log
) {
$this->apiUrl = $this->config->get('externalApiUrl');
$this->apiKey = $this->config->get('externalApiKey');
}
public function fetchCustomerData(string $customerId): ?array
{
$url = $this->apiUrl . '/customers/' . $customerId;
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->apiKey,
'Content-Type: application/json'
]
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
$this->log->error('External API request failed: ' . $httpCode);
return null;
}
return json_decode($response, true);
}
public function syncCustomer(Entity $account): bool
{
$data = [
'name' => $account->get('name'),
'email' => $account->get('emailAddress'),
'phone' => $account->get('phoneNumber')
];
$url = $this->apiUrl . '/customers';
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($data),
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->apiKey,
'Content-Type: application/json'
]
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode === 201) {
$responseData = json_decode($response, true);
$account->set('externalId', $responseData['id']);
return true;
}
$this->log->error('Failed to sync customer: ' . $httpCode);
return false;
}
}Webhook Handler
<?php
namespace Espo\Modules\MyModule\Controllers;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Controllers\Base;
use Espo\Core\Exceptions\BadRequest;
class Webhook extends Base
{
public function postActionReceive(Request $request, Response $response): bool
{
$data = $request->getParsedBody();
if (!$data->event) {
throw new BadRequest('Missing event type');
}
// Verify webhook signature
$signature = $request->getHeader('X-Webhook-Signature');
if (!$this->verifySignature($signature, $request->getBodyContents())) {
throw new Forbidden('Invalid signature');
}
// Process webhook
$service = $this->getRecordService('WebhookEvent');
$service->processWebhook($data->event, $data);
$response->setStatus(200);
return true;
}
private function verifySignature(?string $signature, string $payload): bool
{
if (!$signature) {
return false;
}
$secret = $this->config->get('webhookSecret');
$expectedSignature = hash_hmac('sha256', $payload, $secret);
return hash_equals($expectedSignature, $signature);
}
}File Handling
File Upload and Storage
<?php
namespace Espo\Modules\MyModule\Services;
use Espo\Core\FileStorage\Manager as FileStorageManager;
use Espo\Entities\Attachment;
class DocumentService
{
public function __construct(
private FileStorageManager $fileStorageManager,
private EntityManager $entityManager
) {}
public function uploadFile(string $filePath, string $name, string $type): Attachment
{
$contents = file_get_contents($filePath);
$attachment = $this->entityManager->getNewEntity('Attachment');
$attachment->set([
'name' => $name,
'type' => $type,
'size' => strlen($contents),
'role' => 'Attachment'
]);
$this->entityManager->saveEntity($attachment);
// Store file
$this->fileStorageManager->putContents($attachment, $contents);
return $attachment;
}
public function getFileContents(string $attachmentId): ?string
{
$attachment = $this->entityManager->getEntityById('Attachment', $attachmentId);
if (!$attachment) {
return null;
}
return $this->fileStorageManager->getContents($attachment);
}
}Custom Entry Points
Entry points are public endpoints (no authentication required).
<?php
namespace Espo\Modules\MyModule\EntryPoints;
use Espo\Core\EntryPoint\EntryPoint;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
class PublicDownload implements EntryPoint
{
public function run(Request $request, Response $response): void
{
$id = $request->getQueryParam('id');
if (!$id) {
$response->setStatus(400);
return;
}
// Fetch file
$attachment = $this->entityManager->getEntityById('Attachment', $id);
if (!$attachment || !$attachment->get('isPublic')) {
$response->setStatus(404);
return;
}
// Serve file
$contents = $this->fileStorageManager->getContents($attachment);
$response->setHeader('Content-Type', $attachment->get('type'));
$response->setHeader('Content-Disposition', 'attachment; filename="' . $attachment->get('name') . '"');
$response->writeBody($contents);
}
}Register in metadata (app/entryPoints.json):
{
"publicDownload": {
"className": "Espo\\Modules\\MyModule\\EntryPoints\\PublicDownload"
}
}Access via: ?entryPoint=publicDownload&id=ATTACHMENT_ID
Custom Field Types Reference
Overview
EspoCRM allows creating custom field types that extend the built-in types (varchar, text, enum, etc.). A custom field type requires:
1. Field Metadata - Backend configuration 2. Frontend View - JavaScript view for rendering/editing 3. Templates - Handlebars templates for different modes 4. Translations - Labels for Entity Manager
Field Type Metadata
Create Resources/metadata/fields/{fieldType}.json:
{
"view": "custom:views/fields/my-field",
"params": [
{
"name": "maxLength",
"type": "int"
},
{
"name": "options",
"type": "array"
},
{
"name": "required",
"type": "bool"
}
],
"filter": true,
"textFilter": true,
"textFilterForeign": false,
"personalData": true,
"actualFields": ["value"],
"notActualFields": [],
"fieldDefs": {
"type": "varchar",
"maxLength": 255
},
"converterClassName": "Espo\\Modules\\MyModule\\Classes\\FieldConverters\\MyField"
}Metadata Properties
| Property | Description |
|---|---|
view | Frontend view class path |
params | Configurable parameters in Entity Manager |
filter | Enable filtering in list views |
textFilter | Include in text search |
personalData | Mark as personal data (GDPR) |
actualFields | Database column suffixes |
fieldDefs | Default database column definition |
validatorClassName | Backend validation class |
converterClassName | Field value converter |
Frontend View
Create client/custom/src/views/fields/my-field.js:
define('custom:views/fields/my-field', ['views/fields/base'], function (BaseFieldView) {
return BaseFieldView.extend({
// Template for detail/list view
detailTemplateContent: `
<span class="my-field-value">{{value}}</span>
{{#if formattedValue}}
<span class="formatted">({{formattedValue}})</span>
{{/if}}
`,
// Template for edit view
editTemplateContent: `
<input
type="text"
class="main-element form-control"
name="{{name}}"
value="{{value}}"
autocomplete="off"
maxlength="{{maxLength}}"
{{#if required}}required{{/if}}
>
<div class="help-block hidden"></div>
`,
// Template for list view (optional, uses detailTemplateContent if not defined)
listTemplateContent: `
<span class="my-field-list-value">{{value}}</span>
`,
// Template for search filter
searchTemplateContent: `
<input
type="text"
class="form-control"
name="{{name}}"
value="{{searchValue}}"
placeholder="{{translate 'Search'}}"
>
`,
// Setup method - called before rendering
setup: function () {
BaseFieldView.prototype.setup.call(this);
// Get field params from metadata
this.maxLength = this.params.maxLength || 255;
// Listen for model changes
this.listenTo(this.model, 'change:' + this.name, function () {
if (this.isRendered()) {
this.reRender();
}
}, this);
},
// Data passed to templates
data: function () {
var data = BaseFieldView.prototype.data.call(this);
data.value = this.model.get(this.name);
data.formattedValue = this.formatValue(data.value);
data.maxLength = this.maxLength;
data.required = this.params.required || false;
return data;
},
// Called after DOM is ready
afterRender: function () {
BaseFieldView.prototype.afterRender.call(this);
if (this.isEditMode()) {
// Initialize any plugins
this.$element = this.$el.find('.main-element');
// Add input handlers
this.$element.on('input', function () {
this.trigger('change');
}.bind(this));
}
},
// Get value from DOM (edit mode)
fetch: function () {
var data = {};
data[this.name] = this.$element.val().trim();
return data;
},
// Validate field value
validateRequired: function () {
if (this.isRequired()) {
var value = this.model.get(this.name);
if (!value || value === '') {
var msg = this.translate('fieldIsRequired', 'messages')
.replace('{field}', this.getLabelText());
this.showValidationMessage(msg);
return true;
}
}
return false;
},
// Custom validation
validateMaxLength: function () {
var value = this.model.get(this.name);
if (value && value.length > this.maxLength) {
var msg = this.translate('fieldMaxLength', 'messages')
.replace('{field}', this.getLabelText())
.replace('{max}', this.maxLength);
this.showValidationMessage(msg);
return true;
}
return false;
},
// Format value for display
formatValue: function (value) {
if (!value) return '';
// Custom formatting logic
return value.toUpperCase();
},
// Parse value from input
parseValue: function (value) {
if (!value) return null;
return value.trim().toLowerCase();
},
// Get value for export
getValueForExport: function () {
return this.model.get(this.name) || '';
},
// Search data for filter
fetchSearch: function () {
var value = this.$el.find('input[name="' + this.name + '"]').val();
if (!value) {
return false;
}
return {
type: 'contains',
value: value
};
}
});
});Complete Example: Rating Field
1. Field Metadata
Resources/metadata/fields/rating.json:
{
"view": "custom:views/fields/rating",
"params": [
{
"name": "maxValue",
"type": "int",
"default": 5
},
{
"name": "allowHalf",
"type": "bool",
"default": false
}
],
"filter": true,
"fieldDefs": {
"type": "float",
"default": null
},
"validatorClassName": "Espo\\Modules\\MyModule\\Classes\\FieldValidators\\Rating"
}2. Frontend View
client/custom/src/views/fields/rating.js:
define('custom:views/fields/rating', ['views/fields/base'], function (BaseFieldView) {
return BaseFieldView.extend({
detailTemplateContent: `
<div class="rating-display">
{{#each stars}}
<span class="star {{#if filled}}filled{{/if}}{{#if half}}half{{/if}}">
{{#if filled}}★{{else}}☆{{/if}}
</span>
{{/each}}
<span class="rating-value">({{value}}/{{maxValue}})</span>
</div>
`,
editTemplateContent: `
<div class="rating-input" data-max="{{maxValue}}">
{{#each stars}}
<span
class="star clickable {{#if filled}}filled{{/if}}"
data-value="{{value}}"
>☆</span>
{{/each}}
<input type="hidden" name="{{name}}" value="{{value}}">
<button type="button" class="btn btn-link btn-sm clear-rating">
{{translate 'Clear'}}
</button>
</div>
`,
events: {
'click .star.clickable': function (e) {
var value = $(e.currentTarget).data('value');
this.setRating(value);
},
'click .clear-rating': function () {
this.setRating(null);
}
},
setup: function () {
BaseFieldView.prototype.setup.call(this);
this.maxValue = this.params.maxValue || 5;
this.allowHalf = this.params.allowHalf || false;
},
data: function () {
var data = BaseFieldView.prototype.data.call(this);
var value = this.model.get(this.name);
data.value = value;
data.maxValue = this.maxValue;
data.stars = this.buildStars(value);
return data;
},
buildStars: function (value) {
var stars = [];
value = value || 0;
for (var i = 1; i <= this.maxValue; i++) {
stars.push({
value: i,
filled: i <= value,
half: this.allowHalf && i - 0.5 === value
});
}
return stars;
},
setRating: function (value) {
this.model.set(this.name, value);
this.reRender();
},
fetch: function () {
var data = {};
data[this.name] = this.$el.find('input[name="' + this.name + '"]').val() || null;
if (data[this.name]) {
data[this.name] = parseFloat(data[this.name]);
}
return data;
},
validateRange: function () {
var value = this.model.get(this.name);
if (value !== null && (value < 0 || value > this.maxValue)) {
this.showValidationMessage(
'Rating must be between 0 and ' + this.maxValue
);
return true;
}
return false;
}
});
});3. Backend Validator
Classes/FieldValidators/Rating.php:
<?php
namespace Espo\Modules\MyModule\Classes\FieldValidators;
use Espo\Core\FieldValidation\Validator;
use Espo\Core\FieldValidation\Validator\Data;
use Espo\ORM\Entity;
class Rating implements Validator
{
public function validate(Entity $entity, string $field, Data $data): bool
{
$value = $entity->get($field);
if ($value === null) {
return true; // Allow null
}
$maxValue = $data->getParam('maxValue') ?? 5;
if (!is_numeric($value)) {
return false;
}
if ($value < 0 || $value > $maxValue) {
return false;
}
return true;
}
}4. Translations
Resources/i18n/en_US/Admin.json:
{
"fieldTypes": {
"rating": "Rating"
},
"fieldParams": {
"rating": {
"maxValue": "Maximum Value",
"allowHalf": "Allow Half Stars"
}
}
}5. CSS Styling
client/custom/css/rating-field.css:
.rating-display .star {
font-size: 1.2em;
color: #ccc;
}
.rating-display .star.filled {
color: #ffc107;
}
.rating-input .star {
font-size: 1.5em;
cursor: pointer;
transition: color 0.2s;
}
.rating-input .star:hover {
color: #ffc107;
}
.rating-input .star.filled {
color: #ffc107;
}Complex Field: Address with Autocomplete
Field Metadata
Resources/metadata/fields/addressAutocomplete.json:
{
"view": "custom:views/fields/address-autocomplete",
"params": [
{
"name": "provider",
"type": "enum",
"options": ["google", "mapbox"]
},
{
"name": "countries",
"type": "array"
}
],
"actualFields": ["street", "city", "state", "country", "postalCode"],
"fieldDefs": {
"notStorable": true
},
"fields": {
"street": {
"type": "varchar",
"maxLength": 255
},
"city": {
"type": "varchar",
"maxLength": 100
},
"state": {
"type": "varchar",
"maxLength": 100
},
"country": {
"type": "varchar",
"maxLength": 100
},
"postalCode": {
"type": "varchar",
"maxLength": 20
}
},
"naming": "suffix"
}Frontend View
client/custom/src/views/fields/address-autocomplete.js:
define('custom:views/fields/address-autocomplete', ['views/fields/address'], function (AddressFieldView) {
return AddressFieldView.extend({
editTemplateContent: `
<div class="address-autocomplete">
<input
type="text"
class="form-control autocomplete-input"
placeholder="{{translate 'Start typing address...'}}"
autocomplete="off"
>
<div class="autocomplete-results"></div>
</div>
<div class="address-fields" style="margin-top: 10px;">
<input type="text" class="form-control" name="{{streetName}}" value="{{street}}" placeholder="{{translate 'Street'}}">
<div class="row" style="margin-top: 5px;">
<div class="col-sm-6">
<input type="text" class="form-control" name="{{cityName}}" value="{{city}}" placeholder="{{translate 'City'}}">
</div>
<div class="col-sm-6">
<input type="text" class="form-control" name="{{stateName}}" value="{{state}}" placeholder="{{translate 'State'}}">
</div>
</div>
<div class="row" style="margin-top: 5px;">
<div class="col-sm-6">
<input type="text" class="form-control" name="{{countryName}}" value="{{country}}" placeholder="{{translate 'Country'}}">
</div>
<div class="col-sm-6">
<input type="text" class="form-control" name="{{postalCodeName}}" value="{{postalCode}}" placeholder="{{translate 'Postal Code'}}">
</div>
</div>
</div>
`,
afterRender: function () {
AddressFieldView.prototype.afterRender.call(this);
if (this.isEditMode()) {
this.initAutocomplete();
}
},
initAutocomplete: function () {
var $input = this.$el.find('.autocomplete-input');
var $results = this.$el.find('.autocomplete-results');
$input.on('input', _.debounce(function () {
var query = $input.val();
if (query.length < 3) {
$results.hide();
return;
}
this.fetchAddressSuggestions(query).then(function (suggestions) {
this.renderSuggestions(suggestions);
}.bind(this));
}.bind(this), 300));
},
fetchAddressSuggestions: function (query) {
return this.ajaxGetRequest('AddressAutocomplete', {
query: query,
provider: this.params.provider || 'google'
});
},
renderSuggestions: function (suggestions) {
var $results = this.$el.find('.autocomplete-results');
$results.empty();
suggestions.forEach(function (suggestion) {
var $item = $('<div class="suggestion-item">')
.text(suggestion.formatted)
.data('address', suggestion);
$item.on('click', function () {
this.selectAddress(suggestion);
$results.hide();
}.bind(this));
$results.append($item);
}.bind(this));
$results.show();
},
selectAddress: function (address) {
this.$el.find('[name="' + this.streetName + '"]').val(address.street);
this.$el.find('[name="' + this.cityName + '"]').val(address.city);
this.$el.find('[name="' + this.stateName + '"]').val(address.state);
this.$el.find('[name="' + this.countryName + '"]').val(address.country);
this.$el.find('[name="' + this.postalCodeName + '"]').val(address.postalCode);
this.$el.find('.autocomplete-input').val('');
}
});
});Using Custom Field Types
After creating the field type, use it in entity definitions:
{
"fields": {
"customerRating": {
"type": "rating",
"maxValue": 5,
"allowHalf": true
},
"businessAddress": {
"type": "addressAutocomplete",
"provider": "google"
}
}
}Best Practices
DO:
- Extend appropriate base view (
views/fields/base,views/fields/varchar, etc.) - Implement all view modes (detail, edit, list, search)
- Add proper validation (frontend and backend)
- Support export/import functionality
- Clear cache after adding field types
- Add translations for Entity Manager
DON'T:
- Skip validation
- Hardcode labels (use translations)
- Forget to handle null/empty values
- Skip the search template if field is filterable
- Modify core field types (extend instead)
Resources
Development Workflow Reference
Extension Development Setup
Using ext-template
The official ext-template provides the recommended structure for EspoCRM extensions.
# Clone the template
git clone https://github.com/espocrm/ext-template.git my-extension
cd my-extension
# Install dependencies
composer install
npm installExtension Directory Structure (EspoCRM 7.4+)
my-extension/
├── src/
│ ├── files/
│ │ └── custom/Espo/Modules/MyModule/
│ │ ├── Resources/
│ │ │ ├── metadata/
│ │ │ │ ├── entityDefs/
│ │ │ │ ├── clientDefs/
│ │ │ │ └── scopes/
│ │ │ └── layouts/
│ │ ├── Services/
│ │ ├── Controllers/
│ │ ├── Repositories/
│ │ ├── Hooks/
│ │ └── Entities/
│ └── scripts/
├── tests/
├── package.json
└── manifest.jsonManifest File
{
"name": "My Extension",
"version": "1.0.0",
"acceptableVersions": [">=7.4.0"],
"author": "Your Name",
"description": "Extension description",
"license": "MIT",
"releaseDate": "2024-01-01",
"skipBackup": true
}Building Extension
# Build installable package
npm run build
# Output: build/MyExtension-1.0.0.zipCreating Custom Entities
Step 1: Entity Definition
Create src/files/custom/Espo/Modules/MyModule/Resources/metadata/entityDefs/MyEntity.json:
{
"fields": {
"name": {
"type": "varchar",
"required": true,
"maxLength": 255,
"trim": true,
"view": "views/fields/varchar"
},
"description": {
"type": "text",
"rows": 4
},
"status": {
"type": "enum",
"options": ["New", "In Progress", "Complete", "Cancelled"],
"default": "New",
"required": true,
"audited": true,
"isSorted": true
},
"priority": {
"type": "enum",
"options": ["Low", "Normal", "High", "Urgent"],
"default": "Normal",
"audited": true
},
"dueDate": {
"type": "date",
"audited": true
},
"assignedUser": {
"type": "link"
},
"account": {
"type": "link"
},
"contacts": {
"type": "linkMultiple"
}
},
"links": {
"assignedUser": {
"type": "belongsTo",
"entity": "User",
"foreign": "myEntities"
},
"account": {
"type": "belongsTo",
"entity": "Account",
"foreign": "myEntities"
},
"contacts": {
"type": "hasMany",
"entity": "Contact",
"foreign": "myEntities",
"layoutRelationshipsDisabled": true
},
"teams": {
"type": "hasMany",
"entity": "Team",
"relationName": "EntityTeam",
"layoutRelationshipsDisabled": true
}
},
"collection": {
"orderBy": "createdAt",
"order": "desc"
},
"indexes": {
"name": {
"columns": ["name", "deleted"]
},
"assignedUser": {
"columns": ["assignedUserId", "deleted"]
}
}
}Step 2: Scope Definition
Create src/files/custom/Espo/Modules/MyModule/Resources/metadata/scopes/MyEntity.json:
{
"entity": true,
"object": true,
"layouts": true,
"tab": true,
"acl": true,
"aclActionList": [
"create",
"read",
"edit",
"delete",
"stream"
],
"aclLevelList": [
"all",
"team",
"own",
"no"
],
"aclPortal": true,
"aclPortalLevelList": [
"all",
"account",
"contact",
"own",
"no"
],
"customizable": true,
"type": "Base",
"module": "MyModule",
"stream": true,
"activities": true,
"historyDisabled": false,
"importable": true,
"notifications": true,
"activityStatusList": ["Planned", "Held", "Not Held"]
}Step 3: Client Definitions
Create src/files/custom/Espo/Modules/MyModule/Resources/metadata/clientDefs/MyEntity.json:
{
"controller": "controllers/record",
"iconClass": "fas fa-tasks",
"color": "#6FA8D6",
"createDisabled": false,
"dynamicLogic": {
"fields": {
"dueDate": {
"required": {
"conditionGroup": [
{
"type": "equals",
"attribute": "status",
"value": "In Progress"
}
]
}
}
}
},
"filterList": [
"active",
"completed"
],
"boolFilterList": [
"onlyMy"
],
"defaultFilterData": {
"primary": "active"
},
"sidePanels": {
"detail": [
{
"name": "activities",
"label": "Activities",
"view": "crm:views/record/panels/activities",
"aclScope": "Activities"
}
]
}
}Step 4: Language Translations
Create src/files/custom/Espo/Modules/MyModule/Resources/i18n/en_US/MyEntity.json:
{
"fields": {
"name": "Name",
"description": "Description",
"status": "Status",
"priority": "Priority",
"dueDate": "Due Date",
"assignedUser": "Assigned To",
"account": "Account",
"contacts": "Contacts"
},
"links": {
"assignedUser": "Assigned To",
"account": "Account",
"contacts": "Contacts"
},
"options": {
"status": {
"New": "New",
"In Progress": "In Progress",
"Complete": "Complete",
"Cancelled": "Cancelled"
},
"priority": {
"Low": "Low",
"Normal": "Normal",
"High": "High",
"Urgent": "Urgent"
}
},
"labels": {
"Create MyEntity": "Create MyEntity"
},
"presetFilters": {
"active": "Active",
"completed": "Completed"
},
"boolFilters": {
"onlyMy": "Only My"
}
}Step 5: Service Layer
Create src/files/custom/Espo/Modules/MyModule/Services/MyEntity.php:
<?php
namespace Espo\Modules\MyModule\Services;
use Espo\Services\Record;
use Espo\ORM\Entity;
class MyEntity extends Record
{
protected function beforeCreateEntity(Entity $entity, array $data): void
{
parent::beforeCreateEntity($entity, $data);
// Set default assigned user to creator if not specified
if (!$entity->get('assignedUserId')) {
$entity->set('assignedUserId', $this->user->getId());
}
}
protected function beforeUpdateEntity(Entity $entity, array $data): void
{
parent::beforeUpdateEntity($entity, $data);
// Auto-complete when status set to Complete
if ($entity->isAttributeChanged('status') && $entity->get('status') === 'Complete') {
$entity->set('completedAt', date('Y-m-d H:i:s'));
}
}
}Step 6: Rebuild Cache
bin/command rebuildCreating Custom Fields
Custom Field Type Definition
Create src/files/custom/Espo/Modules/MyModule/Resources/metadata/fields/myFieldType.json:
{
"params": [
{
"name": "required",
"type": "bool",
"default": false
},
{
"name": "maxLength",
"type": "int"
},
{
"name": "customParam",
"type": "varchar"
}
],
"view": "custom:views/fields/my-field-type",
"personalData": false
}Custom Field Backend
Create src/files/custom/Espo/Modules/MyModule/Classes/FieldType/MyFieldTypeType.php:
<?php
namespace Espo\Modules\MyModule\Classes\FieldType;
use Espo\ORM\Entity;
use Espo\ORM\Type\AttributeType;
use Espo\Core\Field\FieldType;
class MyFieldTypeType implements FieldType
{
public function getAttributeParamList(): array
{
return [
AttributeType::VARCHAR,
];
}
public function getActualAttributeParamList(Entity $entity, string $field): array
{
return [
AttributeType::VARCHAR,
];
}
}Custom Field Frontend
Create client/custom/src/views/fields/my-field-type.js:
define('custom:views/fields/my-field-type', ['views/fields/varchar'], function (Dep) {
return Dep.extend({
setup: function () {
Dep.prototype.setup.call(this);
// Custom setup logic
this.customParam = this.params.customParam || '';
},
afterRender: function () {
Dep.prototype.afterRender.call(this);
// Custom rendering logic
},
validateRequired: function () {
if (this.params.required) {
if (!this.model.get(this.name)) {
var msg = this.translate('fieldIsRequired', 'messages')
.replace('{field}', this.getLabelText());
this.showValidationMessage(msg);
return true;
}
}
},
fetch: function () {
var data = {};
data[this.name] = this.$element.val() || null;
return data;
}
});
});Custom API Endpoints
Step 1: Define Route
Create src/files/custom/Espo/Modules/MyModule/Resources/metadata/api.json:
{
"routes": [
{
"route": "/MyEntity/:id/customAction",
"method": "post",
"controller": "MyModule:MyEntity",
"action": "customAction"
}
]
}Step 2: Create Controller
Create src/files/custom/Espo/Modules/MyModule/Controllers/MyEntity.php:
<?php
namespace Espo\Modules\MyModule\Controllers;
use Espo\Core\Controllers\Record;
use Espo\Core\Api\Request;
use Espo\Core\Api\Response;
use Espo\Core\Exceptions\BadRequest;
use Espo\Core\Exceptions\Forbidden;
use stdClass;
class MyEntity extends Record
{
public function postActionCustomAction(Request $request, Response $response): stdClass
{
$id = $request->getRouteParam('id');
if (!$id) {
throw new BadRequest();
}
$data = $request->getParsedBody();
if (!$this->acl->check($this->name, 'edit')) {
throw new Forbidden();
}
// Delegate to service layer
$service = $this->getRecordService();
$result = $service->customAction($id, $data);
return $result->getValueMap();
}
}Step 3: Implement Service Method
<?php
namespace Espo\Modules\MyModule\Services;
use Espo\Services\Record;
use Espo\Core\Exceptions\NotFound;
use stdClass;
class MyEntity extends Record
{
public function customAction(string $id, stdClass $data): object
{
$entity = $this->getEntity($id);
if (!$entity) {
throw new NotFound();
}
// Business logic
$entity->set('status', $data->status ?? 'In Progress');
$entity->set('processedAt', date('Y-m-d H:i:s'));
$this->entityManager->saveEntity($entity);
return $entity;
}
}Step 4: Call from Frontend
this.ajaxPostRequest('MyEntity/' + id + '/customAction', {
status: 'Complete'
}).then(response => {
console.log('Action completed', response);
this.model.set(response);
});Custom Repositories
Creating Custom Repository
Create src/files/custom/Espo/Modules/MyModule/Repositories/MyEntity.php:
<?php
namespace Espo\Modules\MyModule\Repositories;
use Espo\Core\Repositories\Database;
use Espo\ORM\Entity;
class MyEntity extends Database
{
protected function beforeSave(Entity $entity, array $options = []): void
{
parent::beforeSave($entity, $options);
// Repository-level validation or data transformation
if ($entity->isNew()) {
$entity->set('customIdentifier', $this->generateIdentifier());
}
}
private function generateIdentifier(): string
{
// Generate unique identifier
$prefix = 'ME-';
$number = $this->getNewNumber();
return $prefix . str_pad($number, 6, '0', STR_PAD_LEFT);
}
private function getNewNumber(): int
{
$query = $this->entityManager
->getQueryBuilder()
->select()
->from('MyEntity')
->select('COUNT(*) as count')
->build();
$sth = $this->entityManager->getQueryExecutor()->execute($query);
$row = $sth->fetch();
return ($row['count'] ?? 0) + 1;
}
public function findActive(): \Espo\ORM\Collection
{
return $this->where([
'status!=' => ['Complete', 'Cancelled']
])->find();
}
}Layouts
List Layout
Create src/files/custom/Espo/Modules/MyModule/Resources/layouts/MyEntity/list.json:
[
{
"name": "name",
"width": "30"
},
{
"name": "status",
"width": "15"
},
{
"name": "priority",
"width": "15"
},
{
"name": "assignedUser",
"width": "15"
},
{
"name": "dueDate",
"width": "15"
},
{
"name": "createdAt",
"width": "10"
}
]Detail Layout
Create src/files/custom/Espo/Modules/MyModule/Resources/layouts/MyEntity/detail.json:
[
{
"label": "Overview",
"rows": [
[
{"name": "name"},
{"name": "status"}
],
[
{"name": "assignedUser"},
{"name": "priority"}
],
[
{"name": "account"},
{"name": "dueDate"}
],
[
{"name": "description", "fullWidth": true}
]
]
},
{
"label": "Contacts",
"rows": [
[
{"name": "contacts", "fullWidth": true}
]
]
}
]Development Best Practices
Cache Rebuild Workflow
# After any metadata changes
bin/command rebuild
# Clear cache only (faster, but may miss some changes)
bin/command clear-cache
# Hard rebuild (if issues persist)
rm -rf data/cache/*
bin/command rebuildTesting Extension Installation
# Build extension
npm run build
# Install in test EspoCRM instance
# Administration > Extensions > Upload
# Upload build/MyExtension-1.0.0.zip
# After changes, rebuild extension and reinstall
npm run build
# Uninstall old version via Administration > Extensions
# Install new versionVersion Compatibility
// Check EspoCRM version in code
$version = $this->config->get('version');
if (version_compare($version, '8.0.0', '>=')) {
// EspoCRM 8.0+ features
}
// Use version-specific metadata
// For EspoCRM 7.x
custom/Espo/Modules/MyModule/Resources/metadata/entityDefs/MyEntity.json
// For EspoCRM 8.x+
custom/Espo/Modules/MyModule/Resources/metadata/entityDefs/MyEntity/MyEntity.jsonDebugging Development Issues
# Enable debug mode
# data/config.php
'logger' => [
'level' => 'DEBUG',
],
# Check logs
tail -f data/logs/espo-$(date +%Y-%m-%d).log
# Check for PHP errors
tail -f /var/log/apache2/error.log # or nginx error logModule Dependencies
// In manifest.json
{
"dependencies": {
"Advanced Pack": {
"version": ">=2.14.0"
}
}
}Extension Package Development Reference
Overview
Extension packages are the recommended way to distribute custom functionality in EspoCRM. They provide a clean, version-controlled method for installing, upgrading, and uninstalling custom modules.
Package Structure
MyExtension.zip
├── manifest.json # Package metadata and configuration
├── scripts/ # Lifecycle scripts
│ ├── BeforeInstall.php
│ ├── AfterInstall.php
│ ├── BeforeUninstall.php
│ └── AfterUninstall.php
├── files/ # Files to be copied to application
│ └── custom/
│ └── Espo/
│ └── Modules/
│ └── MyModule/
│ ├── Resources/
│ │ ├── metadata/
│ │ │ ├── entityDefs/
│ │ │ ├── clientDefs/
│ │ │ └── scopes/
│ │ ├── layouts/
│ │ └── i18n/
│ │ ├── en_US/
│ │ └── de_DE/
│ ├── Services/
│ ├── Controllers/
│ ├── Hooks/
│ └── Entities/
└── data/ # Optional: SQL scripts, data files
└── schema.sqlManifest Configuration
Basic manifest.json
{
"name": "My Extension",
"version": "1.0.0",
"acceptableVersions": [
">=8.0.0 <9.0.0"
],
"description": "Description of what the extension does",
"author": "Your Name",
"license": "MIT",
"skipBackup": false,
"delete": []
}Manifest Fields
Required Fields:
name(string) - Display name of the extensionversion(string) - Semantic version (e.g., "1.0.0", "2.1.3")acceptableVersions(array) - EspoCRM version compatibility
Optional Fields:
description(string) - Detailed descriptionauthor(string) - Author/company namelicense(string) - License type (MIT, GPL-3.0, Proprietary, etc.)skipBackup(boolean) - Skip backup creation during install (default: false)delete(array) - Files/directories to remove during installationreleaseDate(string) - Release date (ISO format)checkVersionUrl(string) - URL to check for updates
Version Constraints
Use composer-style version constraints:
{
"acceptableVersions": [
">=8.0.0 <9.0.0" // Works with 8.x but not 9.x
]
}Common patterns:
">=8.0.0"- Version 8.0.0 or higher">=8.0.0 <9.0.0"- Version 8.x only">=7.5.0 <8.2.0"- Specific range"*"- Any version (not recommended)
Delete Array - Cleanup Old Files
Use the delete array to remove files from previous versions or conflicting extensions:
{
"delete": [
"custom/Espo/Modules/OldModule",
"custom/Espo/Custom/Resources/metadata/entityDefs/ObsoleteEntity.json",
"client/custom/modules/old-module"
]
}Important:
- Paths are relative to EspoCRM root
- Use forward slashes (/) on all platforms
- Be careful not to delete core files
Lifecycle Scripts
Lifecycle scripts execute at specific points during installation/uninstallation. They provide access to EspoCRM's services via dependency injection.
Available Lifecycle Hooks
| Script | When Executed |
|---|---|
| BeforeInstall.php | Before files are copied |
| AfterInstall.php | After files are copied, before cache rebuild |
| BeforeUninstall.php | Before files are removed |
| AfterUninstall.php | After files are removed |
Script Template
All scripts follow this structure:
<?php
namespace Espo\Modules\MyModule;
use Espo\Core\Container;
class BeforeInstall
{
private Container $container;
public function run(Container $container): void
{
$this->container = $container;
// Script logic here
}
}BeforeInstall.php - Pre-Installation Validation
Use to validate system requirements and prerequisites:
<?php
namespace Espo\Modules\MyModule;
use Espo\Core\Container;
use Espo\Core\Exceptions\Error;
use Espo\ORM\EntityManager;
class BeforeInstall
{
private Container $container;
public function run(Container $container): void
{
$this->container = $container;
$this->checkRequirements();
$this->validateConfiguration();
}
private function checkRequirements(): void
{
// Check PHP extensions
if (!extension_loaded('curl')) {
throw new Error('MyModule requires PHP cURL extension');
}
// Check PHP version
if (version_compare(PHP_VERSION, '8.1.0', '<')) {
throw new Error('MyModule requires PHP 8.1 or higher');
}
// Check for conflicting extensions
$entityManager = $this->container->get('entityManager');
$existingExtension = $entityManager
->getRDBRepository('Extension')
->where(['name' => 'ConflictingExtension'])
->findOne();
if ($existingExtension) {
throw new Error('Please uninstall ConflictingExtension before installing MyModule');
}
}
private function validateConfiguration(): void
{
$config = $this->container->get('config');
// Check required config values
if (!$config->get('siteUrl')) {
throw new Error('Site URL must be configured before installing MyModule');
}
}
}AfterInstall.php - Post-Installation Setup
Use for database initialization, default data creation, and configuration:
<?php
namespace Espo\Modules\MyModule;
use Espo\Core\Container;
use Espo\ORM\EntityManager;
use Espo\Core\Utils\Config;
use Espo\Core\Utils\Metadata;
use Espo\Core\Utils\File\Manager as FileManager;
class AfterInstall
{
private Container $container;
public function run(Container $container): void
{
$this->container = $container;
$this->createDatabaseSchema();
$this->insertDefaultData();
$this->updateConfiguration();
$this->createDirectories();
}
private function createDatabaseSchema(): void
{
$entityManager = $this->container->get('entityManager');
$pdo = $entityManager->getPDO();
// Execute SQL from package data directory
$sqlFile = 'data/schema.sql';
if (file_exists($sqlFile)) {
$sql = file_get_contents($sqlFile);
$pdo->exec($sql);
}
// Or create tables programmatically
$sql = "
CREATE TABLE IF NOT EXISTS `my_custom_table` (
`id` VARCHAR(24) NOT NULL PRIMARY KEY,
`name` VARCHAR(255),
`status` VARCHAR(50),
`created_at` DATETIME,
`modified_at` DATETIME,
`deleted` TINYINT(1) DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
";
$pdo->exec($sql);
}
private function insertDefaultData(): void
{
$entityManager = $this->container->get('entityManager');
// Create default entities
$defaultCategories = ['Sales', 'Marketing', 'Support'];
foreach ($defaultCategories as $categoryName) {
// Check if already exists
$existing = $entityManager
->getRDBRepository('MyCategory')
->where(['name' => $categoryName])
->findOne();
if (!$existing) {
$category = $entityManager->getNewEntity('MyCategory');
$category->set([
'name' => $categoryName,
'status' => 'Active'
]);
$entityManager->saveEntity($category);
}
}
}
private function updateConfiguration(): void
{
$config = $this->container->get('config');
$configWriter = $this->container->get('configWriter');
// Add custom configuration values
$configWriter->set('myModuleApiKey', $this->generateApiKey());
$configWriter->set('myModuleEnabled', true);
$configWriter->save();
}
private function createDirectories(): void
{
$fileManager = $this->container->get('fileManager');
$directories = [
'data/upload/mymodule',
'data/cache/mymodule',
'data/logs/mymodule'
];
foreach ($directories as $dir) {
if (!file_exists($dir)) {
$fileManager->mkdir($dir, 0755, true);
}
}
}
private function generateApiKey(): string
{
return bin2hex(random_bytes(32));
}
}BeforeUninstall.php - Pre-Uninstallation Cleanup
Use for data validation and backup before removal:
<?php
namespace Espo\Modules\MyModule;
use Espo\Core\Container;
use Espo\Core\Exceptions\Error;
use Espo\ORM\EntityManager;
class BeforeUninstall
{
private Container $container;
public function run(Container $container): void
{
$this->container = $container;
$this->checkDependencies();
$this->backupData();
$this->notifyUsers();
}
private function checkDependencies(): void
{
$entityManager = $this->container->get('entityManager');
// Check if other extensions depend on this one
$dependentExtensions = $entityManager
->getRDBRepository('Extension')
->where(['dependencies' => '%MyModule%'])
->find();
if (count($dependentExtensions) > 0) {
$names = array_map(fn($e) => $e->get('name'), iterator_to_array($dependentExtensions));
throw new Error(
'Cannot uninstall MyModule. These extensions depend on it: ' .
implode(', ', $names)
);
}
// Check for data that will be lost
$recordCount = $entityManager
->getRDBRepository('MyEntity')
->where(['deleted' => false])
->count();
if ($recordCount > 0) {
throw new Error(
"Warning: Uninstalling will delete {$recordCount} MyEntity records. " .
"Please export your data before uninstalling."
);
}
}
private function backupData(): void
{
$entityManager = $this->container->get('entityManager');
$fileManager = $this->container->get('fileManager');
// Export data to JSON
$records = $entityManager
->getRDBRepository('MyEntity')
->find();
$backup = [
'timestamp' => date('Y-m-d H:i:s'),
'version' => '1.0.0',
'records' => []
];
foreach ($records as $record) {
$backup['records'][] = $record->toArray();
}
$backupFile = 'data/mymodule_backup_' . date('Ymd_His') . '.json';
$fileManager->putContents($backupFile, json_encode($backup, JSON_PRETTY_PRINT));
}
private function notifyUsers(): void
{
$entityManager = $this->container->get('entityManager');
// Send notification to admins
$admins = $entityManager
->getRDBRepository('User')
->where(['isAdmin' => true, 'isActive' => true])
->find();
foreach ($admins as $admin) {
$notification = $entityManager->getNewEntity('Notification');
$notification->set([
'type' => 'System',
'userId' => $admin->getId(),
'message' => 'MyModule extension has been uninstalled',
'data' => [
'timestamp' => date('Y-m-d H:i:s')
]
]);
$entityManager->saveEntity($notification);
}
}
}AfterUninstall.php - Post-Uninstallation Cleanup
Use to remove custom tables, clean configuration, and remove residual files:
<?php
namespace Espo\Modules\MyModule;
use Espo\Core\Container;
use Espo\ORM\EntityManager;
class AfterUninstall
{
private Container $container;
public function run(Container $container): void
{
$this->container = $container;
$this->dropDatabaseTables();
$this->cleanConfiguration();
$this->removeCustomDirectories();
}
private function dropDatabaseTables(): void
{
$entityManager = $this->container->get('entityManager');
$pdo = $entityManager->getPDO();
// Drop custom tables
$tables = [
'my_custom_table',
'my_module_settings'
];
foreach ($tables as $table) {
$pdo->exec("DROP TABLE IF EXISTS `{$table}`");
}
}
private function cleanConfiguration(): void
{
$configWriter = $this->container->get('configWriter');
// Remove module-specific config values
$configWriter->remove('myModuleApiKey');
$configWriter->remove('myModuleEnabled');
$configWriter->save();
}
private function removeCustomDirectories(): void
{
$fileManager = $this->container->get('fileManager');
$directories = [
'data/upload/mymodule',
'data/cache/mymodule',
'data/logs/mymodule'
];
foreach ($directories as $dir) {
if (file_exists($dir)) {
$fileManager->removeInDir($dir, true);
$fileManager->rmdir($dir);
}
}
}
}Available Container Services
Common services available in lifecycle scripts:
// Core services
$entityManager = $container->get('entityManager');
$metadata = $container->get('metadata');
$config = $container->get('config');
$configWriter = $container->get('configWriter');
$fileManager = $container->get('fileManager');
$dataManager = $container->get('dataManager');
// Utility services
$dateTime = $container->get('dateTime');
$language = $container->get('language');
$log = $container->get('log');
// Service factory
$serviceFactory = $container->get('serviceFactory');
$myService = $serviceFactory->create('MyEntity');File Organization
Module Directory Structure
Files in the files/ directory are copied directly to the EspoCRM installation:
files/
└── custom/
└── Espo/
└── Modules/
└── MyModule/
├── Resources/
│ ├── metadata/ # Entity and app definitions
│ │ ├── entityDefs/
│ │ │ └── MyEntity.json
│ │ ├── clientDefs/
│ │ │ └── MyEntity.json
│ │ ├── scopes/
│ │ │ └── MyEntity.json
│ │ └── app/
│ │ └── scheduledJobs.json
│ ├── layouts/ # UI layouts
│ │ └── MyEntity/
│ │ ├── detail.json
│ │ └── list.json
│ └── i18n/ # Translations
│ ├── en_US/
│ │ └── MyEntity.json
│ └── de_DE/
│ └── MyEntity.json
├── Services/ # Business logic
│ └── MyEntity.php
├── Controllers/ # API endpoints
│ └── MyEntity.php
├── Hooks/ # Lifecycle hooks
│ └── MyEntity/
│ └── Validation.php
├── Entities/ # Entity classes
│ └── MyEntity.php
├── Repositories/ # Data access
│ └── MyEntity.php
└── Jobs/ # Scheduled jobs
└── MyCustomJob.phpNamespace Convention
All module code MUST follow this namespace pattern:
<?php
namespace Espo\Modules\MyModule\{SubDirectory};
// Examples:
namespace Espo\Modules\MyModule\Services;
namespace Espo\Modules\MyModule\Controllers;
namespace Espo\Modules\MyModule\Hooks\MyEntity;Metadata Example - Entity Definition
files/custom/Espo/Modules/MyModule/Resources/metadata/entityDefs/MyEntity.json:
{
"fields": {
"name": {
"type": "varchar",
"required": true,
"maxLength": 255
},
"status": {
"type": "enum",
"options": ["New", "In Progress", "Complete"],
"default": "New"
},
"description": {
"type": "text"
}
},
"links": {
"account": {
"type": "belongsTo",
"entity": "Account",
"foreign": "myEntities"
}
}
}Metadata Example - Scope Definition
files/custom/Espo/Modules/MyModule/Resources/metadata/scopes/MyEntity.json:
{
"entity": true,
"object": true,
"layouts": true,
"tab": true,
"acl": true,
"module": "MyModule",
"stream": true,
"disabled": false
}Building and Packaging
Manual Packaging
# 1. Create package directory
mkdir MyExtension-1.0.0
cd MyExtension-1.0.0
# 2. Create manifest
cat > manifest.json << 'EOF'
{
"name": "My Extension",
"version": "1.0.0",
"acceptableVersions": [">=8.0.0 <9.0.0"],
"author": "Your Name"
}
EOF
# 3. Create directory structure
mkdir -p files/custom/Espo/Modules/MyModule/Resources/metadata
mkdir -p scripts
# 4. Add your files
cp -r /path/to/your/code/* files/
# 5. Create lifecycle scripts (optional)
touch scripts/AfterInstall.php
# 6. Create ZIP package
zip -r MyExtension-1.0.0.zip manifest.json files/ scripts/
# 7. Package is ready: MyExtension-1.0.0.zipAutomated Build Script
Create build.sh:
#!/bin/bash
NAME="MyExtension"
VERSION="1.0.0"
BUILD_DIR="build"
PACKAGE_NAME="${NAME}-${VERSION}"
# Clean previous builds
rm -rf ${BUILD_DIR}
mkdir -p ${BUILD_DIR}/${PACKAGE_NAME}
# Copy files
cp manifest.json ${BUILD_DIR}/${PACKAGE_NAME}/
cp -r files ${BUILD_DIR}/${PACKAGE_NAME}/
cp -r scripts ${BUILD_DIR}/${PACKAGE_NAME}/
# Create package
cd ${BUILD_DIR}
zip -r ${PACKAGE_NAME}.zip ${PACKAGE_NAME}
cd ..
echo "Package created: ${BUILD_DIR}/${PACKAGE_NAME}.zip"Installation Methods
Via Admin UI
1. Navigate to Administration > Extensions 2. Click Upload button 3. Select your .zip package file 4. Click Install 5. Review changes and confirm 6. Wait for installation to complete 7. Click Rebuild when prompted
Via CLI
# Install extension
php command.php extension --file="path/to/MyExtension-1.0.0.zip"
# Uninstall extension
php command.php extension --uninstall="My Extension"
# List installed extensions
php command.php extension --list
# Rebuild after installation
php command.php rebuildProgrammatic Installation
<?php
use Espo\Core\Container;
use Espo\Core\Utils\File\Manager as FileManager;
$container = $GLOBALS['container'];
$fileManager = $container->get('fileManager');
// Copy extension package to upload directory
$packagePath = 'data/upload/extensions/MyExtension-1.0.0.zip';
$fileManager->copy('/path/to/package.zip', $packagePath);
// Install via API
$extensionManager = $container->get('extensionManager');
$extensionManager->install($packagePath);Upgrade Packages
Create upgrade packages to update existing installations:
Upgrade Package Structure
MyExtension-Upgrade-1.0.0-to-1.1.0.zip
├── manifest.json
├── scripts/
│ └── AfterUpgrade.php
└── files/
└── custom/
└── Espo/
└── Modules/
└── MyModule/
└── (only changed files)Upgrade Manifest
{
"name": "My Extension",
"version": "1.1.0",
"acceptableVersions": [">=8.0.0 <9.0.0"],
"isUpgrade": true,
"upgradeFrom": ["1.0.0"],
"description": "Upgrade from 1.0.0 to 1.1.0",
"delete": [
"custom/Espo/Modules/MyModule/Services/DeprecatedService.php"
]
}AfterUpgrade.php Script
<?php
namespace Espo\Modules\MyModule;
use Espo\Core\Container;
use Espo\ORM\EntityManager;
class AfterUpgrade
{
private Container $container;
public function run(Container $container): void
{
$this->container = $container;
$this->migrateData();
$this->updateConfiguration();
}
private function migrateData(): void
{
$entityManager = $this->container->get('entityManager');
// Update existing records for schema changes
$entities = $entityManager
->getRDBRepository('MyEntity')
->find();
foreach ($entities as $entity) {
// Migrate old field to new field
if (!$entity->get('newField') && $entity->get('oldField')) {
$entity->set('newField', $entity->get('oldField'));
$entityManager->saveEntity($entity);
}
}
}
private function updateConfiguration(): void
{
$configWriter = $this->container->get('configWriter');
// Add new config option
$configWriter->set('myModuleNewFeature', true);
$configWriter->save();
}
}Best Practices
DO: Use Lifecycle Scripts for Data Migration
// ✅ CORRECT - Migrate data in AfterInstall
class AfterInstall
{
public function run(Container $container): void
{
$entityManager = $container->get('entityManager');
// Create default data
$defaultSettings = $entityManager->getNewEntity('MyModuleSettings');
$defaultSettings->set(['enabled' => true]);
$entityManager->saveEntity($defaultSettings);
}
}DO: Provide Clear Uninstall Cleanup
// ✅ CORRECT - Clean up properly in AfterUninstall
class AfterUninstall
{
public function run(Container $container): void
{
// Remove all traces
$this->dropTables($container);
$this->removeConfiguration($container);
$this->cleanFiles($container);
}
}DO: Validate Version Compatibility
{
"acceptableVersions": [
">=8.0.0 <9.0.0"
]
}// ✅ CORRECT - Check EspoCRM version in BeforeInstall
class BeforeInstall
{
public function run(Container $container): void
{
$config = $container->get('config');
$version = $config->get('version');
if (version_compare($version, '8.0.0', '<')) {
throw new Error('Requires EspoCRM 8.0.0 or higher');
}
}
}DO: Handle Errors Gracefully
// ✅ CORRECT - Use try-catch and rollback
class AfterInstall
{
public function run(Container $container): void
{
$entityManager = $container->get('entityManager');
$transactionManager = $container->get('transactionManager');
try {
$transactionManager->run(function () use ($entityManager) {
$this->createSchema($entityManager);
$this->insertData($entityManager);
});
} catch (\Throwable $e) {
$log = $container->get('log');
$log->error('Installation failed: ' . $e->getMessage());
throw $e; // Re-throw to fail installation
}
}
}DON'T: Modify Core Files
// ❌ WRONG - Never modify core files
files/
└── application/ // Don't touch core!
└── Espo/
└── Core/
└── (modified core file)
// ✅ CORRECT - Always use custom/ directory
files/
└── custom/
└── Espo/
└── Modules/
└── MyModule/DON'T: Skip acceptableVersions
// ❌ WRONG - No version check
{
"name": "My Extension",
"version": "1.0.0"
}
// ✅ CORRECT - Always specify compatible versions
{
"name": "My Extension",
"version": "1.0.0",
"acceptableVersions": [">=8.0.0 <9.0.0"]
}DON'T: Leave Orphaned Data
// ❌ WRONG - No cleanup in AfterUninstall
class AfterUninstall
{
public function run(Container $container): void
{
// Does nothing - leaves data behind
}
}
// ✅ CORRECT - Clean up everything
class AfterUninstall
{
public function run(Container $container): void
{
$this->dropCustomTables($container);
$this->removeConfiguration($container);
$this->deleteCustomFiles($container);
}
}DON'T: Hard-Code Database Credentials
// ❌ WRONG - Hard-coded credentials
class AfterInstall
{
public function run(Container $container): void
{
$pdo = new PDO('mysql:host=localhost;dbname=espocrm', 'root', 'password');
}
}
// ✅ CORRECT - Use EntityManager (uses config automatically)
class AfterInstall
{
public function run(Container $container): void
{
$entityManager = $container->get('entityManager');
$pdo = $entityManager->getPDO();
}
}Testing Your Extension
Test Installation
1. Fresh Install: Test on clean EspoCRM instance 2. Verify Files: Check all files copied correctly to custom/ 3. Check Logs: Review data/logs/espo.log for errors 4. Test Functionality: Verify all features work as expected 5. Check Cache: Ensure rebuild completes successfully
Test Uninstallation
1. Before Uninstall: Note all files, tables, and config values 2. Uninstall: Use Admin UI or CLI 3. Verify Cleanup: Ensure all module data is removed 4. Check Residual: No leftover files in custom/Espo/Modules/MyModule/ 5. Database Check: Verify custom tables are dropped
Test Upgrade
1. Install v1.0.0: Install initial version 2. Create Test Data: Add some records 3. Install v1.1.0: Install upgrade package 4. Verify Migration: Check data migrated correctly 5. Test Features: Ensure old and new features work
Common Issues
Issue: Extension Won't Install
Symptoms: Installation fails with generic error
Solution:
// Check logs
tail -f data/logs/espo.log
// Common causes:
// 1. Invalid manifest.json syntax
// 2. Script errors in lifecycle hooks
// 3. Version incompatibility
// 4. Insufficient permissionsIssue: Files Not Copied
Symptoms: Files from package not appearing in custom/
Solution:
# Check file permissions
chmod -R 755 custom/
chown -R www-data:www-data custom/
# Verify ZIP structure
unzip -l MyExtension-1.0.0.zip
# Should show:
# manifest.json
# files/custom/Espo/Modules/...Issue: Cache Not Rebuilding
Symptoms: Changes not visible after install
Solution:
# Manual rebuild
php command.php rebuild
# Clear cache
rm -rf data/cache/*
# Check rebuild logs
tail -f data/logs/espo.logIssue: Database Changes Not Applied
Symptoms: Lifecycle script runs but no database changes
Solution:
// Use transactions and check for errors
class AfterInstall
{
public function run(Container $container): void
{
$log = $container->get('log');
$entityManager = $container->get('entityManager');
try {
$pdo = $entityManager->getPDO();
$sql = "CREATE TABLE IF NOT EXISTS my_table (...)";
$result = $pdo->exec($sql);
$log->info('Table created, rows affected: ' . $result);
} catch (\Exception $e) {
$log->error('Table creation failed: ' . $e->getMessage());
throw $e;
}
}
}Example: Complete Extension Package
See the files/ directory structure for a complete, working example including:
- Manifest with all required fields
- Full lifecycle script suite
- Proper metadata organization
- Service layer implementation
- Database schema setup
- Configuration management
- Cleanup procedures
This provides a production-ready template for building your own extensions.
PHP Code-Quality Anti-Patterns (EspoCRM)
EspoCRM is modern, strictly-typed PHP. The architectural rules (business logic in Services, data access via EntityManager, no Container injection) are covered elsewhere in this skill. This reference adds a set of language-level code-quality defects — robustness and changeability issues that degrade maintainability of custom modules, hooks, and services regardless of architecture.
Source note: The defect families below are derived from CAST Highlight code
quality indicators (https://doc.casthighlight.com/), cross-referenced with the primary
sources CAST cites (the PSR coding standards from php-fig.org, and the PHP migration
guides). Patterns are paraphrased with original EspoCRM-flavored examples; severities
are review guidance, not CAST's proprietary calibration.
These are a statistical signal: one occurrence is minor, but a high density across a module marks it as a cleanup target. Apply an ~80% confidence filter.
---
1. Deprecated PHP4-style constructor naming
Family: Robustness — Severity: MEDIUM
Before PHP 5, a constructor was a method whose name matched the class. Since PHP 5 the constructor is __construct(), and same-named-method constructors were removed in PHP 8 — a class relying on one will silently not construct as intended (the method becomes an ordinary method). In an EspoCRM codebase (PHP 8+) this is a latent bug.
Non-compliant:
class InvoiceCalculator
{
public function InvoiceCalculator(EntityManager $em) // VIOLATION — PHP4 ctor
{
$this->entityManager = $em;
}
}Compliant — use `__construct`, with constructor-promoted, injected dependencies:
class InvoiceCalculator
{
public function __construct(
private EntityManager $entityManager
) {}
}How to spot it: a public method bearing the exact class name, or a child calling parent::ParentClassName(). Note this also violates EspoCRM's DI rule if it grabs the Container instead of typed dependencies.
---
2. Uppercased control-structure keywords
Family: Changeability — Severity: LOW
PHP keywords are case-insensitive, so IF, FOREACH, TRY all run — but mixed casing hurts readability and breaks the consistency PSR-2/PSR-12 require (lowercase keywords). EspoCRM follows PSR coding standards; uppercased control keywords stand out as imported or legacy code.
Non-compliant:
FOREACH ($collection as $entity) { // VIOLATION
IF ($entity->get('isActive')) { // VIOLATION
$this->process($entity);
}
}Compliant:
foreach ($collection as $entity) {
if ($entity->get('isActive')) {
$this->process($entity);
}
}Applies to if, else, elseif, for, foreach, do, while, try, catch, switch. A formatter (php-cs-fixer with the PSR-12 ruleset) fixes all occurrences mechanically — prefer running it over hand-editing.
---
3. goto
Family: Robustness — Severity: MEDIUM
goto jumps unconditionally to a label, obscuring control flow and decoupling the static text of the program from its dynamic execution. It defeats a reader's ability to follow a routine top-to-bottom and is essentially never warranted in EspoCRM service or hook code.
Non-compliant:
function importRows(array $rows): void
{
$i = 0;
loop: // VIOLATION — label + goto
if ($i >= count($rows)) { goto done; }
$this->importRow($rows[$i]);
$i++;
goto loop;
done:
}Compliant — use structured loops and early returns:
function importRows(array $rows): void
{
foreach ($rows as $row) {
$this->importRow($row);
}
}There is no false-positive case here worth carving out: treat any goto as a finding and restructure with loops, break/continue, extracted methods, or early return.
---
4. Empty catch blocks
Family: Robustness — Severity: MEDIUM
A catch with an empty body swallows the exception and lets the program continue as though nothing failed — a frequent source of "it silently did nothing" bugs. In a hook or service, a swallowed ORMException or validation failure can leave an entity in a half-saved state.
Non-compliant:
try {
$this->entityManager->saveEntity($related);
} catch (\Throwable $e) {
// VIOLATION — swallowed; caller believes the save succeeded
}Compliant — log and rethrow (or translate to a domain exception):
try {
$this->entityManager->saveEntity($related);
} catch (\Throwable $e) {
$GLOBALS['log']->error('saveEntity failed: ' . $e->getMessage());
throw new Error('Could not persist related entity', 0, $e);
}False-positive filter: A deliberately best-effort operation may ignore failure, but only with a comment stating why it is safe. An unannotated empty catch is the violation — and on EntityManager writes, swallowing is almost always wrong.
---
How these affect an EspoCRM review
These are mostly LOW/MEDIUM changeability and robustness findings; none blocks a merge on its own. Two carry real bug risk on PHP 8: the PHP4 constructor (#1) will not initialize the object, and an empty catch around an EntityManager write (#4) can mask a failed persist. The casing (#2) and goto (#3) items are best handled by running php-cs-fixer / a static analyzer (PHPStan) across the module rather than spot-fixing. Record findings in the review handoff and clean them up while the file is already open. For PHP defects with a direct security dimension (phpinfo() in production, missing switch default in access-control code), see the WordPress security-validation skill's quality reference.