Project Introduction: an Events GraphQL API, Module and Data Model
Project Introduction: an Events GraphQL API, Module and Data Model
~9 Min. Lesezeit Zuletzt aktualisiert am August 9, 2026
From here on, a single project runs through the rest of this series: a GraphQL API for events, built as a standalone module Mironsoft\Event. Each of the following 16 chapters extends this exact module - no more switching examples.
Functional requirements
An event has a title, a unique identifier (for the single-item query in chapter 15), a description, a location, a start time and an optional end time, an optional capacity, and an active status. In addition, a logged-in customer should be able to mark an event as a favorite (block 5) - which requires an n:m relationship between events and customers, exactly following the same pattern as a wishlist.
Two tables: events and favorites
Starting state of the Event module (block 4)
app/code/Mironsoft/Event/
├── registration.php
├── composer.json
├── etc/
│ ├── module.xml
│ ├── db_schema.xml
│ └── schema.graphqls
├── Api/
│ ├── Data/
│ │ ├── EventInterface.php
│ │ └── EventSearchResultsInterface.php
│ └── EventRepositoryInterface.php
└── Model/
├── Event.php
├── EventRepository.php
└── ResourceModel/
├── Event.php
└── Event/
└── Collection.php<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="mironsoft_event" resource="default" engine="innodb"
comment="Mironsoft Event Table">
<column xsi:type="int" name="event_id" padding="10" unsigned="true"
nullable="false" identity="true" comment="Event ID"/>
<column xsi:type="varchar" name="identifier" nullable="false" length="64"
comment="URL-safe Unique Identifier"/>
<column xsi:type="varchar" name="title" nullable="false" length="255"
comment="Title"/>
<column xsi:type="text" name="description" nullable="true"
comment="Description"/>
<column xsi:type="varchar" name="location" nullable="false" length="255"
comment="Location"/>
<column xsi:type="timestamp" name="start_at" nullable="false"
default="CURRENT_TIMESTAMP" comment="Start Date/Time"/>
<column xsi:type="timestamp" name="end_at" nullable="true"
comment="End Date/Time"/>
<column xsi:type="int" name="capacity" padding="10" unsigned="true"
nullable="true" comment="Capacity"/>
<column xsi:type="smallint" name="is_active" padding="5" unsigned="true"
nullable="false" identity="false" default="1" comment="Is Active"/>
<column xsi:type="timestamp" name="created_at" on_update="false" nullable="false"
default="CURRENT_TIMESTAMP" comment="Created At"/>
<column xsi:type="timestamp" name="updated_at" on_update="true" nullable="false"
default="CURRENT_TIMESTAMP" comment="Updated At"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="event_id"/>
</constraint>
<constraint xsi:type="unique" referenceId="MIRONSOFT_EVENT_IDENTIFIER">
<column name="identifier"/>
</constraint>
</table>
<table name="mironsoft_event_customer_favorite" resource="default" engine="innodb"
comment="Mironsoft Event To Customer Favorite Linkage Table">
<column xsi:type="int" name="event_id" padding="10" unsigned="true"
nullable="false" identity="false" comment="Event ID"/>
<column xsi:type="int" name="customer_id" padding="10" unsigned="true"
nullable="false" identity="false" comment="Customer ID"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="event_id"/>
<column name="customer_id"/>
</constraint>
<constraint xsi:type="foreign" referenceId="MIRONSOFT_EVENT_CUSTOMER_FAVORITE_EVENT_ID_EVENT_EVENT_ID"
table="mironsoft_event_customer_favorite" column="event_id"
referenceTable="mironsoft_event" referenceColumn="event_id"
onDelete="CASCADE"/>
<constraint xsi:type="foreign" referenceId="MIRONSOFT_EVENT_CUSTOMER_FAVORITE_CUSTOMER_ID_CUSTOMER_ENTITY_ENTITY_ID"
table="mironsoft_event_customer_favorite" column="customer_id"
referenceTable="customer_entity" referenceColumn="entity_id"
onDelete="CASCADE"/>
</table>
</schema>The favorites table is only actually populated starting in chapter 18, but it's in the schema from the start - that way, setup:upgrade stays a one-time step for this chapter, instead of being spread across several later chapters.
Service contracts instead of a bare collection
Following this project's conventions (service contracts, repositories instead of direct collection access), Event gets its own API interface:
<?php
declare(strict_types=1);
namespace Mironsoft\Event\Api\Data;
/**
* Service contract for a single event entity.
*/
interface EventInterface
{
public const EVENT_ID = 'event_id';
public const IDENTIFIER = 'identifier';
public const TITLE = 'title';
public const DESCRIPTION = 'description';
public const LOCATION = 'location';
public const START_AT = 'start_at';
public const END_AT = 'end_at';
public const CAPACITY = 'capacity';
public const IS_ACTIVE = 'is_active';
/**
* Returns the event ID.
*
* @return int|null
*/
public function getEventId(): ?int;
/**
* Returns the URL-safe unique identifier.
*
* @return string
*/
public function getIdentifier(): string;
/**
* Returns the event title.
*
* @return string
*/
public function getTitle(): string;
/**
* Returns the event description.
*
* @return string|null
*/
public function getDescription(): ?string;
/**
* Returns the event location.
*
* @return string
*/
public function getLocation(): string;
/**
* Returns the start date/time (Y-m-d H:i:s).
*
* @return string
*/
public function getStartAt(): string;
/**
* Returns the end date/time (Y-m-d H:i:s), or null if open-ended.
*
* @return string|null
*/
public function getEndAt(): ?string;
/**
* Returns the attendee capacity, or null if unlimited.
*
* @return int|null
*/
public function getCapacity(): ?int;
/**
* Returns whether the event is active.
*
* @return bool
*/
public function isActive(): bool;
}<?php
declare(strict_types=1);
namespace Mironsoft\Event\Model;
use Magento\Framework\Model\AbstractModel;
use Mironsoft\Event\Api\Data\EventInterface;
use Mironsoft\Event\Model\ResourceModel\Event as EventResource;
/**
* Event entity model.
*/
class Event extends AbstractModel implements EventInterface
{
/**
* Initializes the resource model.
*
* @return void
*/
protected function _construct(): void
{
$this->_init(EventResource::class);
}
/**
* @return int|null
*/
public function getEventId(): ?int
{
$id = $this->getData(self::EVENT_ID);
return $id !== null ? (int) $id : null;
}
/**
* @return string
*/
public function getIdentifier(): string
{
return (string) $this->getData(self::IDENTIFIER);
}
/**
* @return string
*/
public function getTitle(): string
{
return (string) $this->getData(self::TITLE);
}
/**
* @return string|null
*/
public function getDescription(): ?string
{
$description = $this->getData(self::DESCRIPTION);
return $description !== null ? (string) $description : null;
}
/**
* @return string
*/
public function getLocation(): string
{
return (string) $this->getData(self::LOCATION);
}
/**
* @return string
*/
public function getStartAt(): string
{
return (string) $this->getData(self::START_AT);
}
/**
* @return string|null
*/
public function getEndAt(): ?string
{
$endAt = $this->getData(self::END_AT);
return $endAt !== null ? (string) $endAt : null;
}
/**
* @return int|null
*/
public function getCapacity(): ?int
{
$capacity = $this->getData(self::CAPACITY);
return $capacity !== null ? (int) $capacity : null;
}
/**
* @return bool
*/
public function isActive(): bool
{
return (bool) $this->getData(self::IS_ACTIVE);
}
}bin/magento setup:upgrade
bin/cache-clean configTipp: ResourceModel/Event.php and ResourceModel/Event/Collection.php follow the exact same pattern as in the testimonial module from the Admin Grids & Forms series (_init() with table name/primary key, or the model/resource-model pair) - not reprinted here, to keep the focus on what's new for GraphQL.
With tables and the model triad in place, the data foundation is ready. Chapter 12 begins the actual GraphQL part: the schema for events, including pagination following the connection pattern.