Membersactivities framework technical guide
14 Sep 2026 - dirkvm
MembersActivities Framework 1.1.0 is a reusable domain framework for applications managing members, activities, cost items, subscriptions, payments, tickets and ticket scanning. It is implemented as a Composer package and extends the Controller Framework.
The Composer package declares PHP ^8.3, samoscon/controller-framework ^1.0.31 and the Mollie API package. Mollie remains an optional application-level integration even though its package is available to the framework.
Version 1.1.0 extends the domain model with persistent tickets and ticket scans while retaining the existing Activity and Member hierarchical model.
Version 1.1.0 introduces the following principal technical changes compared with 1.0.31.
| Area | Version 1.1.0 change |
|---|---|
| Ticket domain | Introduces persistent Ticket objects and ticket persistence. |
| Ticket scanning | Introduces TicketScan objects and scan persistence. |
| Ticket security | Introduces an activity-specific ticket_scan AccessToken purpose. |
| Seat reservations | Adds server-side conflict detection before a subscription is created. |
| Google Wallet | Wallet ticket generation is based on actual Ticket objects rather than subscription quantity. |
| Payment status | Payment status changes use the dedicated status mechanism rather than generic payment editing. |
| Remember tokens | remember_tokens.expires_at is nullable. |
| Mail queue | subject, body and recipient are nullable. |
| Composite model | Both Activity and Member explicitly participate in recursive parent-child relationships. |
In version 1.0.31, the quantity of tickets was represented by the quantity property of a subscription. Version 1.1.0 introduces an individual persistent Ticket entity.
This allows individual tickets to have their own token, state, scan history and identity.
The new TicketScan model records an attempt to scan a ticket. The framework can distinguish valid, already-used, cancelled, invalid and wrong-activity results.
Seat availability is no longer only a property of the seat map displayed to the user. The server performs a final check against existing reservations when the subscription is submitted.
Google Wallet integration now works from persistent ticket entities. Each ticket can therefore be represented independently in Google Wallet.
src/
├── commands/
│ ├── DefaultCommand.php
│ ├── admin/
│ │ ├── AddActivityToCompositeCommand.php
│ │ ├── AddMemberToCompositeCommand.php
│ │ ├── AdminHomeCommand.php
│ │ ├── CreateActivityCommand.php
│ │ ├── CreateCostitemCommand.php
│ │ ├── CreateMemberCommand.php
│ │ ├── DeleteActivityCommand.php
│ │ ├── DeleteCostitemCommand.php
│ │ ├── DeleteMemberCommand.php
│ │ ├── DeletePaymentCommand.php
│ │ ├── EditActivityCommand.php
│ │ ├── EditCostitemCommand.php
│ │ ├── EditMemberCommand.php
│ │ ├── EditPaymentCommand.php
│ │ ├── RemoveActivityFromCompositeCommand.php
│ │ ├── RemoveMemberFromCompositeCommand.php
│ │ └── SearchMembersCommand.php
│ ├── downloads/
│ │ ├── DownloadXlsMembersCommand.php
│ │ └── DownloadXlsParticipantsCommand.php
│ ├── mollie/
│ │ ├── OrderToMollieCommand.php
│ │ ├── PaymentToMollieCommand.php
│ │ └── WebhookFromMollieCommand.php
│ └── user/
│ ├── ActivityCommand.php
│ ├── CreatePaymentCommand.php
│ ├── PaymentConfirmationCommand.php
│ ├── PublicActivityCommand.php
│ └── UserActivityCommand.php
│
├── model/
│ ├── activities/
│ │ ├── Activity.php
│ │ ├── ActivityComposite.php
│ │ ├── ActivityMapper.php
│ │ ├── ActivityTypeImplementation.php
│ │ ├── Costitem.php
│ │ ├── CostitemMapper.php
│ │ └── CostitemTypeImplementation.php
│ ├── subscriptions/
│ │ ├── Payment.php
│ │ ├── PaymentMapper.php
│ │ ├── PaymentTypeImplementation.php
│ │ ├── Subscription.php
│ │ ├── SubscriptionMapper.php
│ │ ├── SubscriptionTypeImplementation.php
│ │ ├── SubscriptionValidationStrategy.php
│ │ ├── Ticket.php
│ │ ├── TicketMapper.php
│ │ ├── TicketScan.php
│ │ ├── TicketScanMapper.php
│ │ ├── TicketScanTypeImplementation.php
│ │ └── TicketTypeImplementation.php
│ └── wallet/
│ └── GoogleWalletTicket.php
The Member model remains supplied by the Controller Framework and is normally specialized by the client application.
| Pattern | Where used | Purpose |
|---|---|---|
| Active Record / Domain Object | Activity, Costitem, Payment, Subscription, Ticket, TicketScan; Member comes from Controller Framework | Domain objects represent persisted application entities. |
| Data Mapper | ActivityMapper, CostitemMapper, PaymentMapper, SubscriptionMapper, TicketMapper, TicketScanMapper | Separates persistence operations from domain objects. |
| Abstract Factory | getInstance() implementations | Creates concrete client model objects from database rows. |
| Builder / Type implementation | ActivityTypeImplementation, CostitemTypeImplementation, PaymentTypeImplementation, SubscriptionTypeImplementation, TicketTypeImplementation, TicketScanTypeImplementation | Associates a domain object with classification-specific behaviour. |
| Strategy | SubscriptionValidationStrategy | Allows client applications to define subscription rules independently of the generic command. |
| Decorator | CommandDecorator in Controller Framework | Adds client-specific command behaviour without modifying framework commands. |
| Composite | ActivityComposite and Member parent-child hierarchy | Represents recursive domain hierarchies using objects of the same domain type. |
| Atomic state transition | Ticket claiming | Prevents two concurrent scanners from successfully claiming the same ticket. |
Both Activity and Member use a recursive parent-child relationship.
The important design principle is that the parent and child are instances of the same domain type. This allows client code to work with individual objects and composite objects through a consistent abstraction.
membersactivities\model\activities\Activity is an abstract domain object extending the Controller Framework's DomainObject.
classification.parent_id is present.public ?ActivityTypeImplementation
$activitytypeimplementation = null;
| Method | Return | Purpose |
|---|---|---|
getInstance(array $row) | Activity | Creates a concrete activity or ActivityComposite and attaches its type implementation. |
getParticipants() | ObjectMap | Returns paid participants, including participants inherited through the activity hierarchy. |
subscriptionPeriodOver() | bool | Checks the activity due date and, for composites, relevant child activities. |
getTotalAmountReceived() | float | Sums distinct paid payments associated with cost items of the activity. |
ActivityComposite extends Activity and implements the Composite pattern.
Children are loaded through the Activity mapper. The composite therefore represents a parent Activity while retaining the same base domain type.
This permits an application to treat a single activity and a hierarchy of activities consistently where appropriate.
For a composite, subscriptionPeriodOver() evaluates the relevant child activities.
Member is supplied by the Controller Framework and specialized by the client application, normally as:
class Member extends \controllerframework\members\Member
{
// client-specific behaviour
}
MembersActivities Framework 1.1.0 explicitly uses the same Composite principle for members.
The reference schema supports member groups through parent_id. A Member can therefore act as a parent while one or more other Member objects refer to it as their parent.
The relationship is recursive. A child Member is itself a Member and can therefore have its own children where the application permits this.
This design allows client applications to represent structures such as households or other application-specific member hierarchies without introducing a separate object type for each hierarchy level.
| Property | Meaning |
|---|---|
| name / lastname | Member identity |
| Contact/login email | |
| role | User or administrator role |
| password | Stored password hash managed by authentication infrastructure |
| active | Application membership state |
| subscriptionuntil | Membership validity date |
| parent_id | Optional parent Member relationship |
The Activity and Member models intentionally follow the same object-oriented principle:
| Domain | Parent | Child | Relationship |
|---|---|---|---|
| Activity | Activity | Activity | Parent Activity → Child Activities |
| Member | Member | Member | Parent Member → Child Members |
In both cases the parent is not a different domain type. It is a domain object containing references to objects of the same type.
Costitem represents a subscribable item belonging to an activity.
public ?CostitemTypeImplementation
$costitemtypeimplementation = null;
During getInstance(), the framework:
activity_id is present.| Database property | Meaning |
|---|---|
| id | Identifier |
| description | Human-readable item description |
| classification | Type implementation selector |
| price | Unit price |
| type | Application-defined cost item type |
| activity_id | Owning activity |
Subscription represents a registration by a member for a cost item.
public ?SubscriptionTypeImplementation
$subscriptiontypeimplementation = null;
When instantiated, the framework resolves:
subscription.member
subscription.costitem
subscription.payment
subscriptiontypeimplementation
The payment relation is optional because a subscription can exist before payment has been associated with it.
In version 1.1.0 a subscription can have multiple persistent Ticket objects.
The historical quantity concept should therefore no longer be interpreted as the complete ticket identity model. Individual Ticket records provide the persistent identity of each ticket.
Payment represents a financial transaction and extends the Controller Framework's DomainObject.
public ?PaymentTypeImplementation
$paymenttypeimplementation = null;
| Method | Purpose |
|---|---|
getInstance() | Creates the concrete client payment type and loads the member. |
delete() | Deletes associated subscriptions and then the payment inside a database transaction. |
isPaid() | Returns true when status is exactly paid. |
statusReceived() | Creates the payment status processing flow and delegates status-specific behaviour to the payment type implementation. |
The transaction in Payment::delete() ensures that deletion of the payment and its associated subscriptions is committed atomically; an exception causes a rollback.
membersactivities\model\subscriptions\Ticket is a persistent domain object representing an individual ticket.
| Property | Purpose |
|---|---|
id | Persistent ticket identifier. |
description | Ticket description. |
classification | Ticket type implementation selector. |
subscription_id | Owning subscription. |
token | Unique externally usable ticket token. |
status | Ticket state, such as valid or cancelled. |
used | Indicates whether the ticket has already been claimed. |
used_at | Timestamp of successful ticket claim. |
created_at | Creation timestamp. |
Tickets are identified by a unique token when used by external clients such as a scanner.
$ticket = \model\Ticket::findByToken($token);
The token should be treated as untrusted input and must always be validated on the server.
The Ticket model provides an atomic claim operation:
$success = $ticket->claim();
The operation is successful only when the ticket is still valid and unused.
This protects the ticketing system against simultaneous scans from different devices.
TicketTypeImplementation
The ticket classification determines the concrete ticket behaviour. The default classification is RGLR.
TicketScan represents an individual attempt to scan or validate a Ticket.
| Property | Purpose |
|---|---|
id | Persistent scan identifier. |
description | Scan description. |
classification | Scan type implementation selector. |
ticket_id | Ticket being scanned. |
scanner | Identifier of the scanning device/application. |
scanned_at | Timestamp of the scan. |
result | Validation result. |
valid
already_used
cancelled
invalid
wrong_activity
The exact presentation of these values is a client application concern, but the server remains authoritative for determining the result.
TicketScanTypeImplementation
The default classification is RGLR. Client applications may provide specialized scan behaviour through their own type implementation.
GoogleWalletTicket encapsulates Google Wallet integration.
| Method | Purpose |
|---|---|
auth() | Initializes Google API authentication. |
createClass() | Creates a Wallet class. |
updateClass() | Updates a Wallet class. |
createObject() | Creates a Wallet ticket object. |
updateObject() | Updates a Wallet ticket object. |
createJwt(int $id) | Creates the JWT used to add the ticket to Google Wallet. |
Version 1.1.0 creates Wallet objects from persistent Ticket records.
This replaces the previous model where ticket instances were derived directly from subscription quantity.
Version 1.1.0 contains the following domain mappers:
ActivityMapper
CostitemMapper
SubscriptionMapper
PaymentMapper
TicketMapper
TicketScanMapper
| Mapper | Table | Framework-specific fields |
|---|---|---|
| ActivityMapper | activity | date, duedate, longdescription, start, end, location, parent_id |
| CostitemMapper | costitem | price, type, activity_id |
| SubscriptionMapper | subscription | member_id, costitem_id, payment_id, quantity, remark |
| PaymentMapper | payment | member_id, date, amount, status, type, source |
| TicketMapper | ticket | subscription_id, token, status, used, used_at |
| TicketScanMapper | ticket_scan | ticket_id, scanner, scanned_at, result |
Each mapper extends the Controller Framework Mapper and defines an allowed-field whitelist. This is important when performing update/insert operations through the persistence layer.
The TicketMapper provides ticket-specific persistence operations including token lookup and atomic ticket claiming.
findByToken(string $token): ?Ticket
claimTicket(int $ticketId): bool
The atomic claim operation must be implemented at database level so that concurrent scanners cannot both successfully change the same ticket from unused to used.
The TicketScanMapper persists scan records associated with tickets.
TicketScan
│
└── ticket_id ───► Ticket
Mapper::findAll() accepts a free SQL select clause. Client code must never concatenate untrusted request data into such a clause.
The framework commands are grouped by responsibility.
| Package | Commands |
|---|---|
| admin | Create, edit, delete and composite-management operations for activities, cost items, members and payments; administration and member search. |
| downloads | Member and participant XLS exports. |
| mollie | Generic Mollie payment creation and webhook processing. |
| user | Activity registration, payment creation, payment confirmation and public/user activity flows. |
Ticket scanning may be exposed through client-specific commands or through framework commands depending on the client application's routing and scanner architecture.
Commands are intended to be connected to routes through the Controller Framework configuration, normally controls.xml.
Client applications should use Controller Framework CommandDecorator whenever framework command logic can be reused.
This is especially important for validation strategies, payment descriptions and client-specific ticket or scanner behaviour.
ActivityTypeImplementation
CostitemTypeImplementation
PaymentTypeImplementation
SubscriptionTypeImplementation
TicketTypeImplementation
TicketScanTypeImplementation
The classification field determines the concrete implementation.
For example:
class Activity_RGLR
extends \membersactivities\model\activities\ActivityTypeImplementation
{
// default activity behaviour
}
class Activity_STMP
extends \membersactivities\model\activities\ActivityTypeImplementation
{
public function seatmap(): bool {
return true;
}
}
class Ticket_RGLR
extends \membersactivities\model\subscriptions\TicketTypeImplementation
{
// regular ticket behaviour
}
class TicketScan_RGLR
extends \membersactivities\model\subscriptions\TicketScanTypeImplementation
{
// regular scanner behaviour
}
The client application should use type implementations when behaviour genuinely depends on classification rather than adding classification-specific branching throughout the generic framework.
SubscriptionValidationStrategy is the extension point for business rules determining whether a member may subscribe to a cost item.
public function subscribe(
\controllerframework\members\Member $member,
\membersactivities\model\activities\Costitem $subscribableitem,
array $properties
): array
Client applications can implement different policies, for example:
SubscriptionValidationPublic
SubscriptionValidationUser
SubscriptionValidationAdmin
The concrete strategy should be supplied through controlled application code, normally a CommandDecorator. A request parameter must never be treated as a class name and instantiated dynamically.
The Request object is also used as a controlled communication channel between decorators and wrapped commands. This mechanism is used, among other things, for application-specific validation strategies and Mollie order descriptions.
Authentication and login levels are supplied by the Controller Framework. Commands should explicitly define whether they require no login, a user login or administrator login.
State-changing operations should use POST and validate the CSRF token before modifying data.
Controller Framework 1.0.31 supplies AccessToken. MembersActivities uses it for protected payment operations and version 1.1.0 also uses a dedicated token purpose for ticket scanning.
AccessToken::generate(
'mollie-order',
(string) $paymentId
);
AccessToken::generate(
'ticket_scan',
(string) $activityId
);
The _SALTRAND secret used by the AccessToken mechanism must remain confidential and must not be stored in public source code.
The ticket scanning endpoint should validate the activity-specific ticket_scan token before processing ticket tokens.
Request parameters are untrusted. Validate IDs, email addresses, file names, redirects, query parameters, ticket tokens and all other externally supplied values according to their intended type and context.
The critical trust boundary is the payment amount: the amount must be read from the server-side Payment object and not accepted from a browser-submitted amount.
The order description is intentionally different: the client-specific CommandDecorator can set orderDescription on the Request so that different order types can supply different descriptions without modifying the generic payment command.
The scanner must not consider a ticket valid merely because the token exists.
The server should verify:
The final atomic claim is the authoritative concurrency protection.
Applications using seat maps must consider the difference between displayed availability and actual database state.
The seat conflict check therefore belongs at the server-side subscription creation boundary rather than only in the JavaScript seat map.
Client-side seat maps improve usability, but they are not authoritative for reservation consistency.
| Table | Primary purpose | Important foreign keys |
|---|---|---|
| activity | Activities and activity hierarchy | parent_id → activity.id |
| member | Members and member hierarchy | parent_id → member.id |
| costitem | Subscribable items/prices | activity_id → activity.id |
| payment | Financial transactions | member_id → member.id |
| subscription | Member registration for a cost item | member_id, costitem_id, payment_id |
| ticket | Individual persistent tickets | subscription_id → subscription.id |
| ticket_scan | Ticket scanning history | ticket_id → ticket.id |
| remember_tokens | Remember-me authentication tokens | member-related authentication data |
| mail_queue | Asynchronous email processing | application process data |
remember_tokens.expires_at is nullable.
The following mail_queue fields are nullable:
subject
body
recipient
The reference schema uses InnoDB, utf8mb4 and foreign key constraints. It is intended as a starting schema for a new client application rather than a production migration script.
Payment deletion is implemented transactionally: related subscriptions are deleted first, then the payment itself is deleted; an exception rolls the operation back.
Ticket claiming is a different form of consistency requirement. The important property is not a multi-statement business transaction but an atomic conditional state transition.
Conceptually:
UPDATE ticket
SET
used = 1,
used_at = CURRENT_TIMESTAMP
WHERE
id = :ticket_id
AND status = 'valid'
AND used = 0;
The caller can determine whether the claim succeeded from the affected-row result.
This prevents two concurrent scanners from both receiving a successful claim for the same ticket.
Seat reservation validation must similarly be performed against current server-side database state immediately before the subscription is persisted.
For client-specific multi-step business operations, transactions should be considered whenever several related database changes must succeed or fail as one logical operation.
The mail_queue table supports asynchronous mail processing.
A typical lifecycle is:
The queue records processing information such as attempts, creation/start/send timestamps and an error field.
Version 1.1.0 allows subject, body and recipient to be nullable, which permits the queue to represent records whose mail content is completed at a later processing stage.
A cron command can process pending messages and clean up old sent records.
Activity subclass if not already present.Activity_XXXX extending ActivityTypeImplementation.XXXX in the activity classification field.Payment subclass.Payment_XXXX extending PaymentTypeImplementation.statusReceived() when payment status changes require client-specific processing.Ticket subclass if required.Ticket_XXXX extending TicketTypeImplementation.XXXX in the ticket classification field.TicketScan subclass if required.TicketScan_XXXX extending TicketScanTypeImplementation.XXXX in the scan classification field.Member hierarchy support is based on the existing parent_id self-reference.
Parent Member
│
├── Child Member
└── Child Member
Client-specific code should treat the relationship as a domain hierarchy rather than introducing a separate "MemberGroup" object unless the application's business requirements genuinely require one.
SubscriptionValidationStrategy.initCommand().doExecuteDecorator() for client-specific preparation.controls.xml.Version 1.1.0 introduces new persistent domain entities and therefore requires more than a Composer version change for applications using the new functionality.
{
"require": {
"samoscon/membersactivities-framework": "^1.1.0"
}
}
The Controller Framework remains at version 1.0.31.
Applications should apply controlled database changes for:
ticket;ticket_scan;remember_tokens.expires_at;In version 1.0.31, multiple tickets could be represented by a subscription's quantity. Version 1.1.0 introduces individual Ticket records.
Applications using the ticket functionality should migrate existing relevant subscriptions to individual Ticket records.
The migration must generate unique ticket tokens and preserve any client-specific ticket information.
Applications using Google Wallet should ensure that the required Ticket records exist before using the 1.1.0 Wallet implementation.
Applications introducing ticket scanning should implement:
ticket_scan AccessToken generation;| Class | Type | Key API |
|---|---|---|
membersactivities\model\activities\Activity |
abstract model | getInstance, getParticipants, subscriptionPeriodOver, getTotalAmountReceived |
membersactivities\model\activities\ActivityComposite |
composite model | getChildren, isComposite, subscriptionPeriodOver |
controllerframework\members\Member |
domain model / composite root | parent-child member relationship |
membersactivities\model\activities\Costitem |
abstract model | getInstance |
membersactivities\model\subscriptions\Subscription |
abstract model | getInstance |
membersactivities\model\subscriptions\Payment |
abstract model | getInstance, delete, isPaid, statusReceived |
membersactivities\model\subscriptions\Ticket |
domain model | getInstance, findByToken, claim |
membersactivities\model\subscriptions\TicketScan |
domain model | getInstance, scan persistence |
membersactivities\model\subscriptions\SubscriptionValidationStrategy |
strategy | subscribe, errorcode |
membersactivities\model\activities\ActivityMapper |
mapper | getChildren, getParticipants |
membersactivities\model\activities\CostitemMapper |
mapper | persistence / allowed fields |
membersactivities\model\subscriptions\SubscriptionMapper |
mapper | persistence / allowed fields |
membersactivities\model\subscriptions\PaymentMapper |
mapper | persistence / allowed fields |
membersactivities\model\subscriptions\TicketMapper |
mapper | findByToken, claimTicket, persistence / allowed fields |
membersactivities\model\subscriptions\TicketScanMapper |
mapper | scan persistence / allowed fields |
membersactivities\model\activities\ActivityTypeImplementation |
type implementation | seatmap and activity-specific extension points |
membersactivities\model\activities\CostitemTypeImplementation |
type implementation | extension point |
membersactivities\model\subscriptions\SubscriptionTypeImplementation |
type implementation | extension point |
membersactivities\model\subscriptions\PaymentTypeImplementation |
type implementation | statusReceived, getSubscription, Wallet support |
membersactivities\model\subscriptions\TicketTypeImplementation |
type implementation | ticket-specific extension point |
membersactivities\model\subscriptions\TicketScanTypeImplementation |
type implementation | ticket-scan-specific extension point |
membersactivities\model\wallet\GoogleWalletTicket |
integration service | auth, create/update class/object, createJwt |