This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
The Liturgical Calendar API is a PSR-7/15/17 compliant REST API written in PHP 8.4+ that generates the Roman Catholic liturgical calendar for any given year. It calculates mobile festivities and determines the precedence of solemnities, feasts, and memorials. The API serves calendar data for nations, dioceses, or groups of dioceses in various formats: JSON, YAML, XML, or ICS.
Key characteristics:
- Data is based on official sources (Roman Missal editions, Magisterial documents, Dicastery Decrees)
- Historically accurate: calendars for past years reflect rules as they existed at that time
- Supports multiple languages via gettext
- PSR-7 compliant HTTP message handling with PSR-15 middleware architecture
The API requires at least 6 PHP workers since some routes make internal requests to other routes:
# Using composer (recommended)
composer start
# Using the script directly
./start-server.sh
# Manual approach with environment
PHP_CLI_SERVER_WORKERS=6 php -S localhost:8000 -t publicStop the server:
composer stop
# or
./stop-server.shEnvironment configuration: Copy .env.example to .env.local and configure:
API_PROTOCOL(http|https)API_HOST(localhost in dev)API_PORT(8000 in dev)API_BASE_PATH(empty in dev, e.g. /api/dev in production)APP_ENV(development|test|staging|production) - Required in non-localhost environmentsdevelopment/test: Allow default password ifADMIN_PASSWORD_HASHis unset (for testing convenience)staging/production: RequireADMIN_PASSWORD_HASHto be configured (throws exception if missing)- Invalid/unset values throw exception (fail-closed security)
JWT Authentication configuration (required for protected endpoints):
JWT_SECRET- Secret key for signing tokens (minimum 32 characters, generate withphp -r "echo bin2hex(random_bytes(32));")JWT_ALGORITHM- Algorithm for signing (default: HS256)JWT_EXPIRY- Access token expiry in seconds (default: 3600 = 1 hour), must be positiveJWT_REFRESH_EXPIRY- Refresh token expiry in seconds (default: 604800 = 7 days), must be positiveADMIN_USERNAME- Admin username for authentication (default: admin)ADMIN_PASSWORD_HASH- Argon2id password hash (generate withpassword_hash('password', PASSWORD_ARGON2ID))- Required in
stagingandproductionenvironments - Optional in
developmentandtestenvironments (defaults to password "password")
- Required in
Protected Routes: The following routes require JWT authentication (via HttpOnly cookie or Authorization: Bearer <token> header):
PUT /data/{category}/{calendar}- Create calendar dataPATCH /data/{category}/{calendar}- Update calendar dataDELETE /data/{category}/{calendar}- Delete calendar data
Authentication Endpoints:
POST /auth/login- Authenticate with username/password, returns access and refresh tokens (sets HttpOnly cookies)POST /auth/refresh- Refresh access token using refresh token (reads from cookie or body)POST /auth/logout- End session and clear HttpOnly cookiesGET /auth/me- Check authentication state (returns user info from token, essential for cookie-based auth)
Cookie-Based Authentication (Phase 2.5):
The API supports full cookie-only authentication where:
- Tokens are stored in HttpOnly cookies (not accessible to JavaScript, mitigating token theft via XSS)
JwtAuthMiddlewarereads token from cookie first, falls back to Authorization headerRefreshHandlerreads refresh token from cookie, no request body needed- Frontend uses
credentials: 'include'to send cookies automatically
CORS Configuration:
CORS_ALLOWED_ORIGINS- Comma-separated list of allowed origins for credentialed CORS requests- Default:
*(all origins allowed - not recommended for production with cookies) - Example:
CORS_ALLOWED_ORIGINS=https://example.com,https://admin.example.com - Auth endpoint errors only reflect validated origins (security measure)
- Default:
See Authentication Roadmap for implementation details.
# Install dependencies first
composer install
# Run all PHPUnit tests
composer test
# Run quick tests (excludes slow tests)
composer test:quick
# Static analysis (PHPStan level 10)
composer analyse
# Code style checking
composer lint
# Auto-fix code style issues
composer lint:fix
# Lint OpenAPI schema with Redocly
composer lint:openapi
# Parallel syntax checking
composer parallel-lintFor the UnitTestInterface web-based integrity checker:
# Start WebSocket server
composer ws:start
# Stop WebSocket server
composer ws:stopIn VSCode, use Ctrl+Shift+B and select litcal-tests-websockets.
-
Entry Point:
public/index.php- Locates project root via
composer.json - Loads environment with Dotenv
- Configures error handling and logging
- Instantiates
Routerand callsroute()
- Locates project root via
-
Router:
src/Router.php- Implements PSR-7 request/response handling
- Determines endpoint from URL path
- Delegates to appropriate Handler
- Runs PSR-15 middleware pipeline (ErrorHandling, Logging)
-
Handlers:
src/Handlers/- All extend
AbstractHandler(implements PSRRequestHandlerInterface) - Each handler manages one primary route
- Key handlers:
CalendarHandler: Calculated liturgical calendar for a specific year (/calendar)- Returns liturgical events with dates, resolved precedence, suppressions/transfers
- Performs full calendar calculation based on the year and calendar parameters
EventsHandler: All possible liturgical events for a calendar (/events)- Returns event definitions with
event_keyIDs (no dates or calculations) - Provides a catalog of events available for a given calendar
- Useful for frontends to populate selection lists (e.g., datalists)
- Returns event definitions with
MetadataHandler: Calendar metadata (/metadata)RegionalDataHandler: Regional calendar data (/calendars)MissalsHandler: Missal metadata (/missals)DecreesHandler: Dicastery decrees (/decrees)TestsHandler: Test data (/tests)EasterHandler: Easter calculations (/easter)SchemasHandler: JSON schemas (/schemas)Auth/LoginHandler: JWT authentication (/auth/login)- POST endpoint that accepts username and password
- Returns JWT access token and refresh token
Auth/RefreshHandler: Token refresh (/auth/refresh)- POST endpoint that accepts refresh token
- Returns new JWT access token
- All extend
-
Response: Handlers use
Negotiatorto determine content type (JSON/YAML/XML/ICS) based on Accept header orreturn_typeparameter
Models: src/Models/
Calendar/: Calendar generation logic and liturgical event models- Used by
CalendarHandlerto perform calendar calculations for a specific year
- Used by
RegionalData/: National/diocesan/wider region calendar data structuresMissalsPath/: Roman Missal metadataEventsPath/: Event catalog models (all possible events withevent_keyIDs)- Used only by
EventsHandlerto serve event lists to frontend applications - NOT used by
CalendarHandlerfor calendar calculation
- Used only by
Decrees/: Decree metadataLectionary/: Lectionary readingsAuth/: Authentication modelsUser.php: User authentication (currently environment-based)
LitCalItem.php: Individual liturgical event representation (calculated, with dates)PropriumDeSanctisEvent.php: Saints/feasts event modelPropriumDeTemporeEvent.php: Temporal cycle event model
Enums: src/Enum/
- Type-safe enumerations for liturgical concepts
LitColor: Liturgical colorsRomanMissal: Missal editionsLitLocale: Supported locales (includes manually defined locales like Latinla/la_VAplus ICU-based locales)Route,PathCategory: API routingAscension,Epiphany,CorpusChristi: Movable feast configurations- Use
EnumToArrayTraitfor common array conversions
HTTP Layer: src/Http/
Enum/: HTTP-specific enums (AcceptHeader,RequestMethod,StatusCode, etc.)Exception/: Custom HTTP exceptions (includingUnauthorizedException,ForbiddenException)Middleware/: PSR-15 middleware (ErrorHandling, Logging, JwtAuthMiddleware)Server/: Middleware pipeline implementationNegotiator.php: Content negotiation logic
Services: src/Services/
JwtService.php: JWT token generation, verification, and refresh
Params: src/Params/
- Request parameter validation and processing
Utilities:
src/Utilities.php: General utility functionssrc/DateTime.php: Liturgical date calculationssrc/LatinUtils.php: Latin text processingsrc/Health.php: System health checks and integrity validation
JSON Data: jsondata/sourcedata/
missals/: Propriums from different Roman Missal editionspropriumdetempore/: Temporal cycle eventspropriumdesanctis_*/: Saints and feasts by edition (1970, 2002, 2008, US 2011, IT 1983)
calendars/: Regional calendar definitionsnations/: National calendarsdioceses/: Diocesan calendarswider_regions/: Multi-diocese regions
lectionary/: Lectionary readings by cycledecrees/: Dicastery decree metadata
Translations: i18n/
- gettext
.po/.potfiles for UI strings - Managed via Weblate integration
Schemas: jsondata/schemas/
- JSON Schema definitions for API responses and source data validation
- OpenAPI specification (
openapi.json) - Source data schemas:
DiocesanCalendar.json: Schema for diocesan calendar source filesNationalCalendar.json: Schema for national calendar source filesWiderRegionCalendar.json: Schema for wider region source files- Wider regions are transversal layers applied to national calendars (not standalone calendars)
PropriumDeSanctis.json: Schema for Sanctorale (Proper of Saints) events in Roman MissalPropriumDeTempore.json: Schema for Temporale events in Roman MissalLitCalDecreesSource.json: Schema for Dicastery for Divine Worship decreesLitCalTest.json: Schema for test source filesLitCalTranslation.json: Schema for i18n data
- Create handler class extending
AbstractHandlerinsrc/Handlers/ - Implement
handle(ServerRequestInterface $request): ResponseInterface - Set allowed methods, accept headers, content types in constructor
- Add route case in
Router::route()switch statement - Use
Negotiatorfor content-type negotiation - Return PSR-7
ResponseInterface
Events use LitCalItem model with properties:
name: Event namedate: DateTime objectcolor: Array ofLitColorenumstype:LitGradeenum (solemnity, feast, memorial, etc.)common:LitCommonenum arraygrade: Numeric precedence value
Calendar calculation in CalendarHandler determines:
- Movable feast dates (Easter-based)
- Event precedence and coincidence handling
- Suppression/transfer rules
Response Format Negotiation:
Use Negotiator::negotiateResponseContentType() to respect:
return_typequery parameter (json|yaml|xml|ics)Acceptheader- Default fallback (JSON)
Return appropriate PSR-7 Response with correct Content-Type header.
IMPORTANT: The return_type query parameter is only intended for the /calendar endpoint.
This parameter exists to allow browser-based viewing of calendar responses without requiring control over the Accept header.
Admin endpoints and other API routes should use standard Accept header content negotiation only.
Do NOT add return_type handling to admin or other non-calendar endpoints.
Language Negotiation:
IMPORTANT: Always use Negotiator::pickLanguage() for Accept-Language header processing, never use PHP's \Locale::acceptFromHttp().
$locale = Negotiator::pickLanguage($request, [], LitLocale::LATIN);Why this matters:
- PHP's
\Locale::acceptFromHttp()relies on ICU (International Components for Unicode) data, which does not include Latin (la,la-VA,la_VA) - Latin is not part of the Unicode CLDR because it's not a living language with modern locale conventions
- The API manually supports Latin in
LitLocale::$values = ['la', 'la_VA'] Negotiator::pickLanguage()merges these manual locales with ICU-based locales for complete coverage
Language Tag Normalization:
The Negotiator class normalizes language tags from Accept-Language headers to ensure consistent matching:
- Hyphens → Underscores:
en-USbecomesen_us - Lowercase conversion:
en-USbecomesen_us(noten_US) - Specificity calculation:
substr_count(tag, '_') + 1en(0 underscores) → specificity 1en_us(1 underscore) → specificity 2en_us_x_custom(3 underscores) → specificity 4
- Sorting priority: Tags are sorted by quality (q parameter) first, then by specificity (more specific tags first)
This normalization ensures that la, la-VA, and la_VA all match consistently against LitLocale::LATIN.
All handlers must follow this pattern:
// CORRECT - handles Latin and all other locales properly
$locale = Negotiator::pickLanguage($request, [], LitLocale::LATIN);
if ($locale && LitLocale::isValid($locale)) {
$params['locale'] = $locale;
}
// WRONG - will fail for Latin locales
$locale = \Locale::acceptFromHttp($request->getHeaderLine('Accept-Language'));Use LoggerFactory::create() to instantiate PSR-3 compliant Monolog loggers:
- Logs to
logs/directory - Different log files for different subsystems
- Rotation and retention configurable
PHPUnit Tests: phpunit_tests/
ApiTestCase.php: Base test class with common functionalityRoutes/: Tests for each route handlerMethods/: HTTP method validation testsEnum/: Enum behavior tests
Test Groups:
- Regular tests: Fast validation tests
@group slow: Integration tests requiring API calls
Integrity Checks: External web interface at Liturgical-Calendar/UnitTestInterface provides comprehensive calendar data validation via WebSocket backend.
- Stable branch:
stable(stable releases) - Development branch:
development(active development and testing, default branch) - Feature branches: Always branch off
development, notstable - Pull requests: Always target
developmentbranch, neverstabledirectly - Release flow: Changes merge from feature branches →
development→stableafter community testing - Test locally before submitting PR
Creating a feature branch:
git checkout development
git pull origin development
git checkout -b feature/your-feature-name- PHP >= 8.4 (uses modern syntax like
array_find) - Extensions: intl, zip, calendar, yaml, gettext, curl, json, xml, etc.
- System
gettextpackage with language packs - Optional:
apcufor caching (experimental) - Docker: Use provided
Dockerfilefor containerized deployment
All markdown files must conform to rules in .markdownlint.yml:
- Line length: Maximum 180 characters (code blocks and tables excluded)
- Lists: Must be surrounded by blank lines (MD032)
- Code blocks: Must be surrounded by blank lines (MD031)
- Code blocks in lists: Must be indented to match the list item's content indentation
- For numbered lists: Indent 3 spaces after the number and period
- Example: If list item is
1. Item, code block starts at column 4 (3 spaces indent)
- Fenced code blocks: Use ``` style, not indented code blocks (MD046)
- Ordered lists: Use sequential numbering (1, 2, 3...) not all 1's (MD029)
- Tables: Columns must be vertically aligned using consistent spacing (MD060)
Example of properly indented code block in a list:
1. **Step one**
```bash
composer install
```
2. **Step two**
```php
$router = new Router();
```
Example of properly aligned table:
| Column A | Column B | Column C |
|----------|----------|----------|
| Short | Medium | Longer |
| Value | Value | Value |IMPORTANT: Always lint markdown files after editing them.
Automatic Pre-Commit Hook:
This project uses CaptainHook to automatically lint markdown files before commit. When you stage markdown files (.md),
the pre-commit hook will run composer lint:md to check for linting issues.
To reinstall hooks after configuration changes:
vendor/bin/captainhook install --forceManual Linting Commands:
# Lint all markdown files (via composer)
composer lint:md
# Auto-fix markdown issues (via composer)
composer lint:md:fix
# Lint a specific markdown file
markdownlint CLAUDE.md
# Lint all markdown files
markdownlint "**/*.md"
# Auto-fix issues where possible
markdownlint --fix CLAUDE.md
# Using npx (no installation required)
npx --yes markdownlint-cli CLAUDE.mdCommon Issues and Solutions:
- Nested code blocks: When demonstrating markdown code blocks that contain other code blocks, use different fence lengths:
- Outer block: 5 backticks (`````)
- Inner blocks: 3 backticks (```)
- This prevents the parser from interpreting inner blocks as actual markdown
- Ordered lists (MD029): Use sequential numbering (1, 2, 3...) not all 1's
- Missing language specifiers (MD040): Always specify language after opening code fence (e.g.,
```bash,```php,```json) - Line length (MD013): Keep lines under 180 characters (excludes code blocks and tables)
- Blank lines around lists (MD032): Surround lists with blank lines
- Blank lines around code blocks (MD031): Surround code blocks with blank lines
- Table alignment (MD060): Use consistent spacing to align table columns vertically
- Timezone: Always
Europe/Vatican - Year Range: 1970-9999 (MIN_YEAR=1969 exclusive, MAX_YEAR=10000 exclusive)
- Autoloading: PSR-4 autoload configured in
composer.jsonforLiturgicalCalendar\Apinamespace - Code Quality: PHPStan level 10, PSR-12 coding standards via PHP_CodeSniffer
- Hooks: CaptainHook for git hooks (see
captainhook.json)