AI coding guide for [PROJECT_NAME] Drupal project.
Communication style:
- Code over explanations - provide implementations, not descriptions
- Be direct, skip preambles
- Assume Drupal expertise - no over-explaining basics
- Suggest better approaches with code
- Show only changed code sections with minimal context
- Complete answers in one response when possible
- Use Drupal APIs, not generic PHP
- Ask if requirements are ambiguous
Response format:
- Production-ready code with imports/dependencies
- Inline comments explain WHY, not WHAT
- Include full file paths
- Proper markdown code blocks
- Platform: Drupal [VERSION] [single site | multisite]
- Context: [Brief description]
- Architecture: [Architecture description]
- Security: [Security level]
- Languages: [single | multilingual: list languages]
- Custom Entities: [List if any]
- Role System: [Describe roles]
- Use Cases: [Main use cases]
CRITICAL: Before writing dates to .md files, run date command first.
Never use example dates (e.g., "2024-01-01") - always use actual system date.
Branches: main (prod), staging (test), feature/*, bugfix/*, hotfix/*, release/*
Flow: staging → feature/name → PR → merge to staging → eventually main
Hotfix: main → hotfix/name → merge to main + staging, tag release
Commit format: [type]: description (max 50 chars)
Types: feat, fix, docs, style, refactor, perf, test, chore, config
Before PR: Run phpcs, phpstan, tests, drush cex
Don'ts: No direct commits to main/staging, no --force on shared branches, no credentials in code
.gitignore essentials:
web/core/
web/modules/contrib/
web/themes/contrib/
vendor/
web/sites/*/files/
web/sites/*/settings.local.php
.ddev/
node_modules/
.envWeb root: web/ (or docroot/, html/ - check for dir with index.php and core/)
git clone [REPOSITORY_URL] && cd [PROJECT_DIR]
ddev start && ddev [BUILD_COMMAND]DDEV: ddev ssh (container), ddev describe (info), ddev drush [cmd]
Location: .ddev/commands/host/[name]
WARNING: Don't use ## #ddev-generated comments - they break command recognition.
# Core commands
ddev drush status # Status check
ddev drush cr # Cache rebuild
ddev drush cex # Config export
ddev drush cim # Config import
ddev drush updb # Database updates
# Database & PHP eval
ddev drush sql:query "SELECT * FROM node_field_data LIMIT 5;"
ddev drush php:eval "echo 'Hello World';"
# Test services and entities
ddev drush php:eval "var_dump(\Drupal::hasService('entity_type.manager'));"
ddev drush php:eval "\$node = \Drupal::entityTypeManager()->getStorage('node')->load(1); var_dump(\$node->getTitle());"
ddev drush php:eval "var_dump(\Drupal::config('system.site')->get('name'));"
ddev drush php:eval "var_dump(\Drupal::service('custom_module.service_name'));"
# Quick setup (pull from platform)
ddev pull platform -y && ddev drush cim -y && ddev drush cr && ddev drush uliddev composer outdated 'drupal/*' # Check updates
ddev composer update drupal/[module] --with-deps # Update module
ddev composer require drupal/core:X.Y.Z drupal/core-recommended:X.Y.Z --update-with-all-dependencies # Core updateScripts in composer.json: build, deploy, test, phpcs, phpstan
Store in .ddev/.env (gitignored). Access: $_ENV['VAR']. Restart DDEV after changes.
Structure: ./patches/{core,contrib/[module],custom}/
In composer.json → extra.patches:
"drupal/module": {"#123 Fix": "patches/contrib/module/fix.patch"}Sources: local files, Drupal.org issue queue, GitHub PRs Always include issue numbers in descriptions. Monitor upstream for merged patches.
# PHPStan - static analysis
ddev exec vendor/bin/phpstan analyze web/modules/custom --level=1
# PHPCS - coding standards check
ddev exec vendor/bin/phpcs --standard=Drupal web/modules/custom/
# PHPCBF - auto-fix coding standards
ddev exec vendor/bin/phpcbf --standard=Drupal web/modules/custom/
# Rector - code modernization (run in container)
ddev ssh && vendor/bin/rector process web/modules/custom --dry-run
# Upgrade Status - Drupal compatibility check
ddev drush upgrade_status:analyze --allConfig files: phpstan.neon, phpcs.xml, rector.php
Run before: commits, PRs, Drupal upgrades
# PHPUnit
ddev exec vendor/bin/phpunit web/modules/custom
ddev exec vendor/bin/phpunit web/modules/custom/[module]/tests/src/Unit/MyTest.php
ddev exec vendor/bin/phpunit --coverage-html coverage web/modules/custom
# Codeception
ddev exec vendor/bin/codecept run [acceptance|functional|unit]
ddev exec vendor/bin/codecept run --steps --debug --html
# Debug failed tests
ddev exec vendor/bin/phpunit --testdox --verbose [test-file]Drupal test types (in tests/src/): Unit/ (isolated), Kernel/ (minimal bootstrap), Functional/ (full Drupal), FunctionalJavascript/
Codeception structure: tests/{acceptance,functional,unit,_support,_data,_output}/, config: codeception.yml
# Xdebug
ddev xdebug on|off # Toggle (disable when not debugging for perf)
# Container & DB access
ddev ssh # Web container
ddev mysql # MySQL CLI
ddev mysql -e "SELECT..." # Direct query
ddev export-db --file=backup.sql.gz # Export
ddev import-db --file=backup.sql.gz # Import
# Logs
ddev logs -f # Container logs (follow)
ddev drush watchdog:show --count=50 --severity=Error
# State
ddev drush state:get|set|delete [key] [value]IDE: PhpStorm (port 9003), VS Code (PHP Debug extension)
Tips: ddev describe (URLs/services), ddev debug (DDEV issues), Twig debug in development.services.yml
# Cache
ddev drush cr # Rebuild all
ddev drush cache:clear [render|dynamic_page_cache|config]
# Redis (if enabled)
ddev redis-cli INFO stats|memory
ddev redis-cli FLUSHALL # Clear Redis
# DB performance
ddev mysql -e "SELECT table_name, round(((data_length+index_length)/1024/1024),2) 'MB' FROM information_schema.TABLES WHERE table_schema=DATABASE() ORDER BY (data_length+index_length) DESC;"
ddev mysql -e "SHOW VARIABLES LIKE 'slow_query%';"
ddev drush sql:query "OPTIMIZE TABLE cache_bootstrap, cache_config, cache_data, cache_default, cache_discovery, cache_dynamic_page_cache, cache_entity, cache_menu, cache_render;"Optimization: Enable page cache + dynamic page cache, CSS/JS aggregation, Redis/Memcache, CDN for assets, image styles with lazy loading
- SOLID/DRY: Follow SOLID principles, extract repeated logic
- PHP 8.1+: Use strict typing:
declare(strict_types=1); - Drupal Standards: PSR-12 based, English comments only
Location: /web/modules/custom/[prefix]_[module_name]/
Naming: [prefix]_[descriptive_name] (e.g., d_, custom_) - prevents conflicts with contrib
[prefix]_[module_name]/
├── [prefix]_[module_name].{info.yml,module,install,routing.yml,permissions.yml,services.yml,libraries.yml}
├── src/ # PSR-4: \Drupal\[module_name]\[Subdir]\ClassName
│ ├── Entity/ # Custom entities
│ ├── Form/ # Forms (ConfigFormBase, FormBase)
│ ├── Controller/ # Route controllers
│ ├── Plugin/{Block,Field/FieldWidget,Field/FieldFormatter}/
│ ├── Service/ # Custom services
│ └── EventSubscriber/ # Event subscribers
├── templates/ # Twig templates
├── css/ & js/ # Assets
PSR-4: src/Form/MyForm.php → \Drupal\my_module\Form\MyForm
// 1. Enums instead of magic numbers
enum EntityStatus: string {
case Draft = 0;
case Published = 1;
}
// 2. Getter methods instead of direct field access
public function getStatus(): int {
return (int) $this->get('status')->value;
}
// 3. Safe migrations with backward compatibility
function [module]_update_XXXX() {
$manager = \Drupal::entityDefinitionUpdateManager();
$field = $manager->getFieldStorageDefinition('field_name', 'entity_type');
if ($field) {
$new_def = BaseFieldDefinition::create('field_type')->setSettings([...]);
$manager->updateFieldStorageDefinition($new_def);
drupal_flush_all_caches();
\Drupal::logger('module')->info('Migration completed.');
}
}Migration safety: Backup DB, test on staging, ensure backward compatibility, log changes, have rollback plan.
// Database API - always use placeholders, never raw SQL
$query = \Drupal::database()->select('node_field_data', 'n')
->fields('n', ['nid', 'title'])->condition('status', 1)->range(0, 10);
$results = $query->execute()->fetchAll();
// Dependency Injection - avoid \Drupal:: static calls in classes
class MyService {
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}
}
// Caching - use tags and contexts
$build = [
'#markup' => $content,
'#cache' => ['tags' => ['node:' . $nid], 'contexts' => ['user'], 'max-age' => 3600],
];
\Drupal\Core\Cache\Cache::invalidateTags(['node:' . $nid]);
\Drupal::cache()->set($cid, $data, time() + 3600, ['my_module']);// Queue API - for heavy operations
$queue = \Drupal::queue('my_module_processor');
$queue->createItem(['data' => $data]);
// QueueWorker plugin: @QueueWorker(id="...", cron={"time"=60})
// Entity System - always use entity type manager
$storage = \Drupal::entityTypeManager()->getStorage('node');
$node = $storage->load($nid);
$query = $storage->getQuery()
->condition('type', 'article')->condition('status', 1)
->accessCheck(TRUE)->sort('created', 'DESC')->range(0, 10);
$nids = $query->execute();
// Form API - extend FormBase, implement getFormId(), buildForm(), validateForm(), submitForm()
$form['field'] = ['#type' => 'textfield', '#title' => $this->t('Name'), '#required' => TRUE];
$form_state->setErrorByName('field', $this->t('Error message.'));
// Translation - always use t() for user-facing strings
$this->t('Hello @name', ['@name' => $name]);
// Config API
$config = \Drupal::config('my_module.settings')->get('key');
\Drupal::configFactory()->getEditable('my_module.settings')->set('key', $value)->save();
// Permissions
user_role_grant_permissions($role_id, ['permission']);
user_role_revoke_permissions($role_id, ['permission']);- Type declarations/hints required, PHPDoc for classes/methods
- Align
=>in arrays,=in variable definitions - Controllers: final classes, DI, keep thin
- Services: register in
services.yml, single responsibility - Logging:
\Drupal::logger('module')->notice('message') - Entity updates: always use update hooks in
.install, maintain backward compatibility
Key paths: /web/ (or docroot/), /web/modules/custom/, /config/sync/, /web/sites/default/settings.php, /patches/, /tests/
Development paths: routes → routing.yml, forms → src/Form/, entities → src/Entity/, permissions → permissions.yml, updates → .install
# Setup
ddev drush pm:enable language locale content_translation config_translation
ddev drush language:add pl && ddev drush language:add es
ddev drush locale:check && ddev drush locale:update
# Enable content translation
ddev drush config:set language.content_settings.node.article third_party_settings.content_translation.enabled trueDetection: Configure at /admin/config/regional/language/detection - use URL prefix (/en/, /pl/) for SEO
Custom entities: Add translatable = TRUE to @ContentEntityType, use ->setTranslatable(TRUE) on fields
In code: $this->t('Hello @name', ['@name' => $name]) | In Twig: {{ 'Hello'|trans }}
Common issues: Missing translations → locale:update, content not translatable → check Language settings tab
ddev drush cex # Export config
ddev drush cim # Import config
ddev drush config:status # Show differencesPrinciples: HTTPS required, sanitize input, use DB abstraction (no raw SQL), env vars for secrets, proper access checks
# Security updates
ddev drush pm:security
ddev composer update drupal/core-recommended --with-dependencies
ddev composer update --security-only
# Audit
ddev drush role:perm:list
ddev drush watchdog:show --severity=Error --count=100Hardening: chmod 444 settings.php, chmod 755 sites/default/files, disable PHP in files dir
Code: Use placeholders in queries, Html::escape() for output, $account->hasPermission() for access, Form API for validation
ddev drush pm:enable jsonapi
# Optional: ddev composer require drupal/jsonapi_extrasEndpoints:
GET /jsonapi/node/article # List all
GET /jsonapi/node/article/{uuid}?include=field_image,uid # With relations
GET /jsonapi/node/article?filter[status]=1&sort=-created&page[limit]=10
POST /jsonapi/node/article (Content-Type: application/vnd.api+json, Authorization: Bearer {token})
ddev composer require drupal/graphql drupal/graphql_compose
ddev drush pm:enable graphql graphql_compose
# Explorer at /admin/config/graphqlddev composer require drupal/simple_oauth && ddev drush pm:enable simple_oauth
openssl genrsa -out keys/private.key 2048 && openssl rsa -in keys/private.key -pubout -out keys/public.key
# POST /oauth/token with grant_type, client_id, client_secret, username, password
# Use: Authorization: Bearer {access_token}cors.config:
enabled: true
allowedOrigins: ['http://localhost:3000']
allowedMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS']
allowedHeaders: ['*']
supportsCredentials: true- Fully Decoupled: Drupal API + React/Vue/Next.js frontend
- Progressively Decoupled: Drupal pages + JS framework for interactive components
- Hybrid: Mix of Drupal templates and API-driven sections
OAuth tokens (not basic auth), rate limiting, HTTPS, validate input, API documentation (drupal/openapi)
ddev composer require drupal/metatag drupal/pathauto drupal/simple_sitemap drupal/redirect drupal/schema_metatag
ddev drush pm:enable metatag metatag_open_graph metatag_twitter_cards pathauto simple_sitemap redirect schema_metatag
ddev drush simple-sitemap:generate # Generate sitemap at /sitemap.xml
ddev drush pathauto:generate # Generate URL aliasesConfigure at /admin/config/search/metatag/global:
- Organization:
@type: Organization, name, url, logo, sameAs - Article:
@type: Article, headline[node:title], datePublished, author, image - Open Graph: og:title, og:description, og:image, og:url
- Twitter Cards: twitter:card
summary_large_image, twitter:title, twitter:image
ddev composer require drupal/hreflang && ddev drush pm:enable hreflangTwig: {% for lang in languages %}<link rel="alternate" hreflang="{{ lang.id }}" href="..."/>{% endfor %}
CSS/JS aggregation, BigPipe, WebP images, lazy loading, responsive image styles
Testing: Google Rich Results Test, Facebook Sharing Debugger, PageSpeed Insights
On-page: title tags (50-60 chars), meta descriptions (150-160), H1 unique, clean URLs, alt attributes Technical: sitemap submitted, robots.txt, canonical URLs, Schema.org, HTTPS, Core Web Vitals Multilingual: hreflang tags, language-specific sitemaps, canonical per language
JS Aggregation Issues: Missing .libraries.yml deps, wrong load order, drupalSettings unavailable → Add deps (core/jquery, core/drupal, core/drupalSettings, core/once), use once() not .once(), test with aggregation enabled
CSS: BEM naming, SCSS/SASS, organize by component, use SDC when applicable
Theme location: /web/themes/custom/[theme_name]/
cd web/themes/custom/[theme_name]
npm install # Setup
npm run dev|build|watch # Dev/prod/watch (or gulp/gulp dist/gulp watch)
gulp sass|js|images|fonts # Individual tasks
gulp lint:scss|lint:js # Lintingglobal:
css: { theme: { css/style.css: {} } }
js: { js/global.js: {} }
dependencies: [core/drupal, core/jquery, core/drupalSettings, core/once]Enable debugging (development.services.yml): twig.config: { debug: true, auto_reload: true, cache: false }
Naming: node--[type]--[view-mode].html.twig, paragraph--[type].html.twig, block--[type].html.twig, field--[name]--[entity].html.twig
Override: Enable debug → view source for suggestions → copy from core/themes → place in templates/ → ddev drush cr
Template directory structure:
templates/
├── block/ # Block templates
├── content/ # Node templates
├── field/ # Field templates
├── form/ # Form element templates
├── layout/ # Layout templates
├── misc/ # Miscellaneous templates
├── navigation/ # Menu and navigation
├── paragraph/ # Paragraph templates
└── views/ # Views templates
function [theme]_preprocess_node(&$variables) {
$variables['custom_var'] = $variables['node']->bundle();
}
function [theme]_preprocess_paragraph(&$variables) {
$variables['type'] = $variables['paragraph']->bundle();
}
function [theme]_theme_suggestions_node_alter(array &$suggestions, array $variables) {
$node = $variables['elements']['#node'];
if ($node->hasField('field_layout') && !$node->get('field_layout')->isEmpty()) {
$suggestions[] = 'node__' . $node->bundle() . '__' . $node->get('field_layout')->value;
}
}Drupal 10.1+ core, or ddev composer require drupal/sdc for 10.0
Structure: components/[name]/ with [name].component.yml, [name].twig, optional .css/.js
component.yml:
name: Card
props: { type: object, properties: { title: { type: string }, link: { type: string } } }
slots: { content: { title: Content } }Usage: {% include '[theme]:card' with { title: node.label, link: url } %} or {% embed %} for slots
Commands: ddev drush sdc:list, ddev drush pm:enable sdc
rm -rf node_modules package-lock.json && npm install # Reset deps
rm -rf dist/ css/ js/compiled/ # Clear build cachePerformance: Minify for prod, imagemin, critical CSS, font-display:swap, CSS/JS aggregation, AdvAgg module
- Visual verification: Check indicators display correctly on all pages
- Color scheme: GREEN (Local), BLUE (DEV), ORANGE (STG), RED (PROD)
- Never commit "LOCAL" as value in
environment_indicator.indicator.ymlfor production! Always use "PROD" and red color.
MANDATORY: Document work in "Tasks and Problems" section. Use real date (date command). Document: modules, fixes, config changes, optimizations, problems/solutions.
# Create /web/modules/custom/[prefix]_[name]/ with:
# - [prefix]_[name].info.yml (name, type:module, core_version_requirement:^10||^11, package:Custom)
# - [prefix]_[name].module (hooks), .routing.yml, .permissions.yml, .services.yml as needed
ddev drush pm:enable [prefix]_[name] && ddev drush crddev export-db --file=backup.sql.gz # Backup
ddev composer update drupal/core-recommended drupal/core-composer-scaffold --with-dependencies
ddev drush updb && ddev drush cr # Updates + cache// In [module].install
function [module]_update_10001() {
// Use EntityDefinitionUpdateManager for field changes
// Check field exists, update displays, log completion
drupal_flush_all_caches();
}ddev exec vendor/bin/phpunit web/modules/custom/[module]/tests # PHPUnit
ddev exec vendor/bin/codecept run # Codeception
# Dirs: tests/src/Unit/, Kernel/, Functional/; tests/acceptance/ddev drush role:perm:list [role] # List
# PHP: user_role_grant_permissions($role_id, ['perm1']); drupal_flush_all_caches();ddev drush cr # Clear cache
ddev restart # Restart containers
ddev xdebug on|off # Debug mode
ddev drush watchdog:show --count=50 # Check logsddev drush cr # Standard
rm -rf web/sites/default/files/php/twig/* && ddev drush cr # Twig
ddev drush sql:query "TRUNCATE cache_render;" && ddev drush cr # Nuclearddev drush sql:cli # Check connection (SELECT 1;)
ddev drush updb && ddev drush entity:updates # Pending updates
ddev mysql -e "REPAIR TABLE [name];" # Repair tableddev restart # Soft restart
ddev stop && ddev start # Full restart
ddev delete -O && ddev start # Recreate containers
ddev logs # View logsddev composer why-not drupal/[module] # Check deps
ddev composer require drupal/[module] && ddev drush pm:enable [module]
ddev drush updb && ddev drush entity:updates # Schema issuesddev exec chmod -R 775 web/sites/default/files
ddev exec chmod 444 web/sites/default/settings.phpddev drush config:set system.logging error_level verbose
ddev logs && ddev drush watchdog:show --count=50
ddev exec tail -f /var/log/php/php-fpm.log # Check fatal errorsddev drush config:status # Check status
ddev drush config:set system.site uuid [correct-uuid] # UUID mismatchecho "memory_limit = 512M" >> .ddev/php/php.ini && ddev restart
# Or: ddev exec php -d memory_limit=1G vendor/bin/drush [cmd]- Project Documentation:
.cursor/TASKS_AND_PROBLEMS.md - Drupal Documentation: https://www.drupal.org/docs
- DDEV Documentation: https://ddev.readthedocs.io/
Complete reference of content types, media types, taxonomies, and custom entities. See "HOW TO DISCOVER FULL ENTITY STRUCTURE" guide above for discovery commands.
content_types[2]{machine_name,label,description,features,key_fields}:
article,Article,News and blog posts,"revisions,menu_ui,content_translation","body,field_image,field_tags,field_category"
page,Basic Page,Static pages,"revisions,menu_ui","body,field_sections"
paragraph_types:
layout_paragraphs[3]: banner,two_column_layout,accordion
content_paragraphs[3]: text_block,quote,call_to_action
media_paragraphs[3]: image_gallery,video_embed,carousel
media_types[3]{machine_name,label,source,source_field}:
image,Image,image,field_media_image
document,Document,file,field_media_document
remote_video,Remote Video,oembed:video,field_media_oembed_video
taxonomies[2]{machine_name,label,description,hierarchy}:
tags,Tags,Content tagging,false
categories,Categories,Content categorization,true
custom_entities:
custom_entity_name:
type: content_entity
base_table: custom_entity
entity_keys: id:id,uuid:uuid,label:name
fields[6]{name,type}:
id,integer
uuid,uuid
name,string
status,boolean
created,timestamp
changed,timestamp
Examples:
- Article → Tags (many-to-many via
field_tags) - Article → Author (many-to-one via
uid) - Page → Paragraphs (one-to-many via
field_sections) - Custom Entity → Node (reference via
field_node_ref)
Common field naming patterns in this project:
field_[name]- Standard field prefixfield_[prefix]_[name]- Module-specific fields (e.g.,field_meta_tags)- Base fields:
title,body,created,changed,uid,status
Key Field Types:
- Reference fields:
entity_reference,entity_reference_revisions - Text fields:
string,text_long,text_with_summary - Date fields:
datetime,daterange,timestamp - Media:
image,file - Structured:
link,address,telephone
Node View Modes:
full- Full content displayteaser- Summary/card displaysearch_result- Search results display- Custom:
[document_custom_view_modes]
Media View Modes:
full- Full media displaymedia_library- Media library thumbnail- Custom:
[document_custom_view_modes]
// Example: Content workflow states
define('ENTITY_STATUS_DRAFT', 0);
define('ENTITY_STATUS_PUBLISHED', 1);
define('ENTITY_STATUS_ARCHIVED', 2);
// Custom entity states
define('CUSTOM_ENTITY_PENDING', 0);
define('CUSTOM_ENTITY_APPROVED', 1);
define('CUSTOM_ENTITY_REJECTED', 2);- View:
access content| Edit own:edit own [type] content| Delete own:delete own [type] content| Admin:administer [type] content
If project uses migrations, document source to destination mappings:
# Example migration mapping
source:
entity_type: legacy_node
bundle: legacy_article
destination:
entity_type: node
bundle: article
field_mapping:
legacy_title → title
legacy_body → body
legacy_image → field_image
legacy_category → field_category- Document all significant changes in "Tasks and Problems" section below
- Follow the format and examples provided
- Review existing entries before making architectural changes
- Always run
datecommand to get current date before adding entries
Format: YYYY-MM-DD | [TYPE] Description — Types: TASK, PROBLEM/SOLUTION, CONFIG, PERF, SECURITY, NOTE
Run date first. Add new entries at top. Include file paths, module names, config keys.
[Add entries here - newest first]
Examples:
2024-01-15 | TASK: Created custom module d_custom_feature for special workflow
2024-01-15 | PROBLEM: Config import failing with UUID mismatch
| SOLUTION: drush config:set system.site uuid [correct-uuid]
2024-01-14 | CONFIG: Enabled Redis cache backend in settings.php
2024-01-14 | PERF: Enabled CSS/JS aggregation and AdvAgg module
2024-01-13 | SECURITY: Applied security update for Drupal core 10.1.8
2024-01-13 | NOTE: Custom entity queries must include ->accessCheck(TRUE/FALSE)