Backend for The Earth App, powered by Drupal 11
This is the second version of the backend system for The Earth App, a comprehensive RESTful API built on top of PHP 8.4 and Drupal 11.3. The module provides a complete backend infrastructure for a social networking platform focused on novelty, activities, and user engagement.
- Overview
- Technical Stack
- Architecture
- Installation
- Core Components
- Security & Performance
- Development
- Testing
Mantle2 is a custom Drupal 11 module that implements a RESTful API backend for The Earth App. It leverages Drupal's entity system, field API, and routing infrastructure while adding custom controllers, services, and event subscribers to create a modern, scalable API platform.
- RESTful API with OpenAPI/Swagger documentation
- User Management with token auth, OAuth, profiles, and social features
- Activity Tracking for environmental activities with custom fields
- Event Management with participation, cancellation, and image submissions
- Prompt System with visibility-aware discovery and response threads
- Article Content with quizzes, moderation, and user attribution
- Gamification with badges, points, quests, and profile cosmetics
- Rate Limiting with configurable per-endpoint and global limits
- CORS Support with origin whitelisting
- Redis Caching with automatic fallback to Drupal cache
- Email Notifications with HTML rendering and verification codes
- PHP: 8.4+
- Drupal Core: 11.3+
- Symfony: 7.3+ (Event Dispatcher, Rate Limiter, Cache)
- Redis: Optional caching layer via
drupal/redismodule - PostgreSQL/MySQL: Database backend (Drupal standard)
- Composer: PHP dependency management
- Bun: JavaScript runtime for development tooling
- PHPUnit: 11.5+ for unit testing
- Drush: 13.7+ for Drupal CLI operations
- PHPStan: Static analysis
- PHP CodeSniffer: Code quality enforcement
- Prettier: Code formatting for PHP, XML, YAML, JSON
{
"drupal/core": "^11.3",
"drupal/json_field": "^1.4", // JSON field storage
"drupal/key": "1.22.0", // API key management
"drupal/smtp": "^1.4", // Email delivery
"drupal/redis": "^1.10", // Redis integration
"drupal/openid_connect": "^3.0@alpha", // OAuth/OpenID providers
"symfony/rate-limiter": "^7.3", // Rate limiting
"symfony/event-dispatcher": "^7.3", // Event system
"symfony/cache": "^7.3" // Cache abstractions
}mantle2/
├── src/
│ ├── Controller/ # API endpoint controllers
│ │ └── Schema/ # OpenAPI schema generators
│ ├── Custom/ # Domain models and enums
│ ├── EventSubscriber/ # Symfony event subscribers
│ ├── Plugin/ # OpenID Connect client plugins
│ └── Service/ # Business logic helpers
├── tests/
│ └── src/
│ ├── Unit/ # PHPUnit tests
│ └── Mocks.php # Test fixtures
├── mantle2.info.yml # Module metadata
├── mantle2.module # Hook implementations
├── mantle2.install # Installation & schema
├── mantle2.routing.yml # API route definitions (200+ endpoints)
├── mantle2.caching.yml # Custom server-side caching definitions
├── mantle2.services.yml # Service container definitions
├── composer.json # PHP dependencies
├── package.json # Dev tooling
└── phpunit.xml.dist # Test configuration
data/contains runtime content used by helpers and tests, including email campaign definitions and cloud service credentials.tests/src/Unit/contains the repo's main safety net: YAML/config validation and helper/unit tests.tests/src/Mocks.phpprovides Drupal container mocks for helpers that expect runtime services..github/workflows/contains the formatting, deploy, and external E2E dispatch workflows.coverage/is generated output from PHPUnit and should not be edited manually.
All API endpoints are implemented as controller methods extending Drupal's ControllerBase:
class UsersController extends ControllerBase
{
public function users(Request $request): JsonResponse
{
// Handle paginated user listing
}
public function login(Request $request): JsonResponse
{
// Authenticate and return bearer token
}
}Business logic is encapsulated in helper services registered in mantle2.services.yml:
- GeneralHelper: Common utilities (pagination, validation)
- UsersHelper: User operations and authentication
- ActivityHelper: Activity-related business logic
- PointsHelper: Points, badges, cosmetics, and quest lifecycle
- OAuthHelper: OAuth token validation and provider linking
- CampaignHelper: Email campaign content and placeholder expansion
- CloudHelper: Cloud service requests and websocket notifications
- RedisHelper: Cache abstraction with fallback
Custom PHP classes in src/Custom/ represent business entities:
- Implement
JsonSerializablefor API responses - Enforce validation in constructors
- Provide type-safe interfaces
class Activity implements JsonSerializable
{
protected string $id;
protected string $name;
protected array $types = [];
protected ?string $description = null;
public const int MAX_TYPES = 5;
public function __construct(
string $id,
string $name,
array $types = [],
?string $description = null,
array $aliases = [],
array $fields = [],
) {
if (count($types) > self::MAX_TYPES) {
throw new InvalidArgumentException('Too many activity types');
}
// ... validation and initialization
}
}Symfony's event dispatcher handles cross-cutting concerns:
- RateLimitSubscriber: Pre-request rate limit enforcement
- RateLimitResponseSubscriber: Appends global and endpoint rate headers
- CorsSubscriber: CORS header injection
- ApiExceptionSubscriber: Global error handling
- ResponseCacheSubscriber: Config-driven read-through/invalidation caching
- PostResponseSubscriber: Post-response badge progress tracking
- PHP 8.4 or higher
- Composer 2.x
- Drupal 11.3+ installed and configured
- Redis server (optional, recommended for production)
- SMTP server credentials for email functionality
-
Clone the repository into your Drupal modules directory:
cd /path/to/drupal/modules/custom git clone < repository-url > mantle2 cd mantle2
-
Install PHP dependencies:
composer install
-
Enable required Drupal modules:
drush en node user comment json_field key field options datetime smtp redis \ openid_connect -y
-
Enable mantle2:
drush en mantle2 -y
This runs the installation hooks in
mantle2.installwhich:- Creates custom content types (Activity, Event, Article, Prompt)
- Creates custom comment types (Activity Comments, Article Comments)
- Defines extensive custom fields with JSON storage
- Sets up user profile fields
- Configures field display settings
-
Configure Redis (optional): Edit
settings.php:$settings['redis.connection']['interface'] = 'PhpRedis'; $settings['redis.connection']['host'] = '127.0.0.1'; $settings['redis.connection']['port'] = 6379; $settings['cache']['default'] = 'cache.backend.redis';
-
Configure SMTP (required for email features):
- Navigate to
/admin/config/system/smtp - Enter SMTP server credentials
- Test email delivery
- Navigate to
-
Clear cache:
drush cr
Access the API documentation:
- OpenAPI Schema:
https://your-domain.com/openapi - Swagger UI:
https://your-domain.com/swagger-ui
Test a simple endpoint:
curl https://your-domain.com/v2/helloResponsibilities:
- User CRUD operations
- Authentication (token login/logout and provider-based OAuth)
- Profile management (photos, privacy, account tiers)
- Social graph (friends and circle management)
- Notifications, badges, and points
- Quest lifecycle and cosmetics
- Email verification, unsubscribe, and password reset flows
- Activity, prompt, article, and event associations
Database Queries:
- Uses both Entity API (
$storage->getQuery()) and direct SQL (Drupal::database()) - Random sorting implemented via
orderRandom()for discovery features - Supports search across multiple fields (username, first name, last name)
Responsibilities:
- Activity catalog management
- Type filtering and categorization
- Activity-user associations
Key Features:
- Supports up to 5 activity types per activity
- JSON field storage for flexible metadata
- Full-text search across name, description, aliases
- Randomized and deterministic list retrieval modes
Responsibilities:
- Event lifecycle management
- Participation tracking
- Date-based filtering
- Event visibility and attendee list management
- Event image submission moderation
Features:
- Date range queries for event discovery
- RSVP/signup/leave participation management
- Event cancellation and uncancel flows
- Event type enumeration
- Geographic location and activity tagging support
Responsibilities:
- Prompt catalog and random prompt delivery
- User response collection
- Visibility-aware filtering and moderation
Logic:
- Tracks user responses
- Prevents duplicate responses per user per prompt
- Supports response update/delete and expiration checks
Responsibilities:
- Article content management
- Content moderation
- Author attribution
- Expiration checks and quiz retrieval
Features:
- Rich text content support via JSON fields
- Tag/ocean metadata support
- Role-gated article creation and updates
Utilities:
paginatedParameters(Request): Validates and extracts pagination paramsfindOrdinal(array, enum): Maps enum values to database integersvalidateJson(string): JSON validation- Various format converters and validators
Public IDs:
The API issues the entity uuid with its dashes stripped (32 hex) as id, and carries the legacy
padded numeric id alongside it as nid. Both shapes are accepted on input.
publicId(EntityInterface): The 32-hex form of an entity's uuidpublicIdOfNode(int|string)/publicIdOfUser(int|string): The same from a bare idformatId(int|string): The legacy numeric id, left-padded to 24 charactersisPublicId(string)/isInternalId(string): Tell the two shapes apartresolveNodeId(?string, string $bundle): The node id behind either shape, or null
Send nid, not id, to anything that talks to the Cloud service directly: cloud keys its KV on the
numeric id. Cloud will resolve a public id through /v2/admin/{bundle}/{uuid}/internal_id and cache
the pair, so a public id costs one extra round trip rather than failing.
User Operations:
getOwnerOfRequest(Request): Extract authenticated user from requestfindBy(string): Flexible user lookup by public id, numeric id, or@usernamefindByPublicId(string): Lookup by the 32-hex public idissueToken(UserInterface): Create bearer token with bounded session countgetUserByToken(string): Resolve and validate bearer tokens (with sliding expiry)revokeToken(string): Revoke active authentication token- Friend/circle relationship management
Caching Abstraction:
RedisHelper::set(string $key, array $data, int $ttl = 900): bool
RedisHelper::get(string $key): ?array
RedisHelper::delete(mixed $key): bool
RedisHelper::exists(string $key): bool
RedisHelper::ttl(string $key): int
RedisHelper::list(string $pattern): array
RedisHelper::cache(?string $key, callable $callback, int $ttl = 900): arrayFeatures:
- Automatic fallback to Drupal cache backend if Redis unavailable
- JSON serialization of complex data structures
- TTL support for automatic expiration
- Connection pooling via Drupal Redis module
Usage Example:
// Store email verification code
RedisHelper::set(
"email_verify:{$userId}",
[
'code' => $code,
'email' => $email,
'created' => time(),
],
900,
); // 15 minutes TTL
// Retrieve and validate
$data = RedisHelper::get("email_verify:{$userId}");
if ($data && $data['code'] === $userInputCode) {
// Verify successful
RedisHelper::delete("email_verify:{$userId}");
}Email Rendering:
- Converts markdown to HTML for email templates
- Applies consistent styling
- Handles inline CSS for email clients
- Supports template variables
class Activity implements JsonSerializable
{
protected string $id;
protected string $name;
protected array $types; // ActivityType[]
protected ?string $description;
protected array $aliases; // Alternative names
protected array $fields; // Custom metadata
public const int MAX_TYPES = 5;
}class Event implements JsonSerializable
{
private string $id;
private int $hostId;
private string $name;
private string $description;
private EventType $type;
private array $activities; // Activity[]|ActivityType[]
private float $latitude;
private float $longitude;
private int $date; // Unix timestamp in milliseconds
private ?int $endDate;
private Visibility $visibility;
private array $attendees;
private array $fields;
}class Notification implements JsonSerializable
{
public string $id;
public string $userId;
public string $title;
public string $message;
public ?string $link;
public string $type; // info, warning, error, success
public string $source;
public bool $isRead;
public int $timestamp;
}AccountType:
FREE: Regular userPRO: Pro user tierWRITER: Writer privilegesORGANIZER: Organizer privilegesADMINISTRATOR: Administrative access
Visibility:
PUBLIC: Visible to allUNLISTED: Hidden from broad listing, visible by context/ownerPRIVATE: Only user
Privacy:
PRIVATE,CIRCLE,MUTUAL,PUBLICsettings for profile field visibility
ActivityType and EventType:
- Enumerated types for categorization (notifications use string severity values)
Configuration:
// Global limits
Authenticated: 120 requests / 60 seconds
Anonymous: 60 requests / 60 seconds
// Per-endpoint limits (examples)
POST /v2/users/login: 3 requests / 60 seconds
POST /v2/users/create: 5 requests / 5 minutes
POST /v2/events/create: 3 requests / 2 minutesImplementation:
- Uses Drupal's expirable key-value store
- Separate counters for global and per-endpoint limits
- Environment variable overrides for global limits
- IP-based tracking (Cloudflare-aware)
- Returns
429 Too Many Requestswith retry headers
Headers Added:
X-RateLimit-Limit: 3
X-RateLimit-Remaining: 2
X-RateLimit-Reset: 1698765432
X-Global-RateLimit-Limit: 120
X-Global-RateLimit-Remaining: 119
Allowed Origins:
[
'https://api.earth-app.com',
'https://earth-app.com',
'https://app.earth-app.com',
'https://cloud.earth-app.com',
'capacitor://localhost', // iOS
'http://localhost', // Android
'http://localhost:3000', // Development only
'http://127.0.0.1:3000', // Development only
'http://localhost:3001', // Development only
'http://127.0.0.1:3001', // Development only
];Headers Set:
Access-Control-Allow-Origin: <matched-origin>
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, Accept,
X-Requested-With, X-Admin-Key
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 3600
Vary: Origin
Error Handling:
- Catches unhandled exceptions in API routes
- Converts to consistent JSON error responses
- Logs errors with context
- Prevents sensitive information leakage in production
- Bearer token authentication with issuance/revocation and sliding expiry
- OAuth provider sign-in/linking (Google, Microsoft, Discord, GitHub, Facebook, Apple)
- Password hashing using Drupal's password API (bcrypt)
- Password strength validation
- Email verification for account creation
- Admin key validation for privileged operations
- Request parameter sanitization
- JSON schema validation
- SQL injection prevention via Entity API and parameterized queries
- XSS protection through Drupal's filtering system
- File upload validation (type, size, permissions)
- IP-based rate limiting
- Separate limits for authenticated vs. anonymous users
- Per-endpoint rate limiting for sensitive operations
- Configurable time windows and thresholds
- Cloudflare IP detection support
- User-level visibility settings (PUBLIC, UNLISTED, PRIVATE)
- Field-level privacy for profile attributes
- Friend/circle-based content filtering
- Profile photo access control
- Origin whitelist enforcement
- Credentials support for trusted domains
- Preflight request handling
// Redis/key-value for token and short-lived verification data
RedisHelper::set("email_verify:{$userId}", ['code' => $code], 900);
// Entity caching via Drupal cache tags
$users = $storage->loadMultiple($uids);
// Config-driven response caching (mantle2.caching.yml)
// ResponseCacheSubscriber sets X-Cache: HIT/MISS- Indexed fields for common queries (username, email, ID)
- Pagination to limit result sets
- Direct SQL for complex queries (random sorting)
- Query object cloning for count queries to avoid duplication
- User entities loaded on-demand
- Related entities fetched only when needed
- JSON fields decoded on access
// Good: Load multiple entities at once
$users = $storage->loadMultiple($uids);
// Good: Paginated with limit
$query->range($offset, $limit);
// Good: Specific field loading
$query->fields('u', ['uid', 'name', 'mail']);Drupal Logger Integration:
Drupal::logger('mantle2')->error('Error message', ['context' => $data]);
Drupal::logger('mantle2')->warning('Warning message');
Drupal::logger('mantle2')->info('Info message');Logged Events:
- Failed login attempts
- Rate limit violations
- Email delivery failures
- Redis connection issues
- API exceptions
- User registrations
- Password changes
-
Install dependencies:
composer install bun install
-
Configure local environment:
// settings.local.php $config['system.logging']['error_level'] = 'verbose'; $settings['redis.connection']['host'] = 'localhost';
-
Enable development modules:
drush en devel devel_generate dblog -y
-
Generate test data:
drush devel-generate-users 50 drush devel-generate-content 100 --types=activity,event,article
The following files should stay aligned with the code that consumes them:
mantle2.services.ymlregisters helpers and event subscribers.mantle2.routing.ymldefines the API surface and OpenAPI metadata.mantle2.caching.ymlmirrors route patterns for read caching and invalidation.drush.services.ymlexposes the Drush command service.mantle2.info.ymldeclares Drupal module dependencies.data/email_campaigns.ymlpowers email campaign rendering and placeholder expansion.
Formatting:
# PHP, YAML, XML, JSON
bun run prettier # Format all files
bun run prettier:check # Check formatting
# PHP-specific
vendor/bin/phpcbf # Auto-fix coding standards
vendor/bin/phpcs # Check coding standardsStatic Analysis:
vendor/bin/phpstan analyse src/Pre-commit Hooks:
Configured via Husky and lint-staged in package.json:
{
"lint-staged": {
"*.{php,xml,dist,json,install,module,yml,md}": "prettier --write"
}
}Generate OpenAPI Schema:
Visit /openapi to see auto-generated schema based on route definitions in mantle2.routing.yml.
Interactive Swagger UI:
Visit /swagger-ui for interactive API testing and documentation.
Schema Annotations: Routes include OpenAPI metadata:
mantle2.users:
path: '/v2/users'
options:
tags: Users
description: Retrieves a list of Earth App users
schema/200: '#Users'
schema/400: Invalid Pagination Parameters
query: # Query parameter schema
limit:
type: integer
minimum: 1
maximum: 100Enable Verbose Errors:
// settings.local.php
$config['system.logging']['error_level'] = 'verbose';
error_reporting(E_ALL);
ini_set('display_errors', true);Database Queries:
drush watchdog:show --type=mantle2
drush ws --tail # Live log tailClear Caches:
drush cr # Full cache rebuild
drush cc views # Clear specific bin
drush redis-cli flushall # Clear RedisLocation: phpunit.xml.dist
Run Tests:
# All tests
vendor/bin/phpunit
# Specific test file
vendor/bin/phpunit tests/src/Unit/GeneralUnitTest.php
# With coverage
vendor/bin/phpunit --coverage-html coverage/Test Structure:
namespace Drupal\Tests\mantle2\Unit;
use PHPUnit\Framework\TestCase;
use Drupal\mantle2\Service\GeneralHelper;
class GeneralUnitTest extends TestCase
{
public function testFormatId()
{
$this->assertEquals('000000000000000000000123', GeneralHelper::formatId(123));
}
}Current pattern:
- Config-oriented tests live under
tests/src/Unit/and validate YAML files withSymfony\Component\Yaml\Yaml. - Most validation tests use
#[DataProvider]to iterate through every service, route, or cache rule. #[TestDox]is used heavily so failures read like requirements.- The common groups are
mantle2/services,mantle2/routing,mantle2/caching,mantle2/drush,mantle2/html,mantle2/cloud, andmantle2/util. tests/src/Mocks.phpsupplies the Drupal container and service doubles needed by helper tests.
Location: tests/src/Mocks.php
Provides test fixtures for:
- User entities
- Activity nodes
- Event nodes
- Request objects
- Service mocks
Use Drush:
# Test API endpoints
drush php-eval "print_r(\Drupal::service('http_kernel')->handle(Request::create('/v2/hello')));"
# Test services
drush php-eval "print(\Drupal\mantle2\Service\GeneralHelper::formatId(123));"Using cURL:
# Health check
curl http://localhost/v2/hello
# Login
curl -X POST http://localhost/v2/users/login \
-H "Content-Type: application/json" \
-d '{"username":"testuser","password":"testpass"}'
# Get users (authenticated)
curl http://localhost/v2/users \
-H "Authorization: Bearer <token>"Using Postman/Insomnia:
Import the OpenAPI schema from /openapi for automatic request generation.
See LICENSE file for details.
- Fork the repository
- Create a feature branch (
git checkout -b feature/amazing-feature) - Commit your changes (
git commit -m 'Add amazing feature') - Run tests and formatting (
vendor/bin/phpunit && bun run prettier:check) - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
For issues, questions, or contributions, please open an issue on the GitHub repository.
Built with ❤️ for The Earth App