Frontend for sunnah.com, a hadith collection website built on the Yii 2 PHP MVC framework. Serves multilingual hadith content (Arabic, English, Urdu, Bangla, Bosnian, Indonesian) organized by collections, books, and chapters, plus a narrator (rijal) browsing module.
All user-facing text — pages, error messages, view templates, new collections, narrator data — must be Islamically appropriate. No haram references (no boyfriend/girlfriend, no music/dating references, etc.). Use halal alternatives. This is non-negotiable for any change here.
- Web server points to
public/→public/index.phpbootstraps Yii 2. - URL routing lives in
application/config/main.php(extensive pattern-based rules — order matters, see Gotchas). - All frontend logic lives in a single Yii module:
application/modules/front/. - Backing data: two MySQL databases (
db= mainhadithdb,searchdb= query logs) + Elasticsearch for full-text search.
application/config/main.php— central config: routes, DB connections, log targets, modules, Memcached.application/modules/front/controllers/— four controllers:Index,Collection,Narrator,Search.application/modules/front/models/— domain models (Hadith,Book,Collection,Chapter,Narrator,HadithMatch,Util,ContactForm) and language-specific hadith subclasses (EnglishHadith,ArabicHadith,UrduHadith,BanglaHadith,BosnianHadith,IndonesianHadith).application/modules/front/models/data/narrator_maps.php— curated Arabic→English maps for narrator pages (word_dict,jarh_tadil,residence,profession,descriptor).application/modules/front/views/— view templates organized by controller;views/layouts/holds the page chrome (main,home,nav_menu,side_panel,searchbox,suite,_posthog).application/components/— CDN caching helpers and the search-engine abstraction.application/controllers/SController.php— shared base controller (breadcrumbs, page title,auto_version()).public/— web root with static assets (CSS, JS, images) plus a handful of bare-PHP files that bypass Yii (share.php,report.php,processer.php,recaptchalib.php,search_redirect.php) — see Gotchas.db/— SQL seed files (00-samplegitdb.sql,01-hadithTable.sql,02-collections.sql,03-bookdata.sql) mounted into the MySQL container bydocker-compose.yml.
Hadith extends ActiveRecordis the base; per-language subclasses each overridetableName()(e.g.{{EnglishHadithTable}},{{ArabicHadithTable}}) and implementprocess_text()for language-specific text munging (PBUH → ﷺ image/glyph, sanad markup, paragraph breaks).EnglishHadithandArabicHadithadditionally overridepopulateReferences()andpopulatePermalink()— these producecanonicalReference,inbookReference,translationReference,sunnahReference,arabicReference, andpermalink. View templates read those properties directly.ArabicHadithparses shortcodes viaThunder\Shortcode([matn],[prematn],[postmatn],[commentary],[narrator id=…],[quran sura=…]) — setsshortcode_parsed = truewhen applied.Book::fetchHadith()returns[englishEntries, arabicEntries, pairs, lastUpdated]; collection views depend on this exact shape.- Hadith display code expects processed model instances. If manually hydrating hadith rows outside the usual
Util/Bookhelpers, callprocess_text()andpopulate($util, $collection, $book)before rendering. - Adding a new language = new
XYZHadith extends Hadith, new DB table, new entry inBook::fetchLangHadith()'s switch, new column inBookData(xyzBookID,xyzBookNum,xyzBookName), new option inviews/layouts/side_panel.php, and alangLoaded[xyz]entry inpublic/js/sunnah.js.
englishURNandarabicURNare integer primary keys per hadith and the only stable identifiers for cross-referencing.matchtable(model:HadithMatch) mapsarabicURN ↔ englishURN.- Canonical fallback permalink is
/urn/<n>; the colon form/<collection>:<hadithNumber>is the user-facing canonical URL when the book is verified.
status == 4→ fully verified collection: bold reference numbers, usescanonicalReference+inbookReference, may have areference_template.status == 6→ single-column display (used by Musnad Ahmad —.col1CSS class inAllHadith).status < 4→ "in progress"; references rendered asenglishReference/arabicReference/sunnahReferenceand shown non-bold (provisional).- Many view templates and
EnglishHadith::populateReferences()/ArabicHadith::populateReferences()branch on this field.
Always call Util (new Util() or $this->util in SController) instead of writing direct queries:
getCollection($name)— singleCollectionrow, cached ascollection:<name>.getBook($collection, $bookID = null, $language = null)— full book list or a singleBook, cached as<col>books,<col>books_arabic,<col>books_english.getChapter($col, $bookID, $babID)/getChapterDataForBook(...)— cached aschapter:<col>_<book>_<bab>/chapters:<col>_<book>.getHadith($urn, $language)— cached as<lang>urn:<urn>; runsprocess_text()+populate()before caching.getCollectionsInfo($mode, $display_only)— cached ascollectionsInfo_<bool>.getHadithCount(),getNextURNInCollection,getPreviousURNInCollection,getURNByNumber,getVerifiedHadithNumber,get_permalink,customSelect,getCarouselHTML,getRamadanURNs/getDhulhijjahURNs/getMuharramURNs,getMatchingUrns.
Follow the same <scope>:<key> cache-key shape if you add new helpers. Narrator caches are versioned (narrator:hadith_clusters:v2:…) — bump the version when the shape changes.
Three layers, picked per route:
CdnHeaders(application/components/CdnHeaders.php) — low-level helper. SetsCache-Control,Cloudflare-CDN-Cache-Control,Cache-Tag: sunnah(configurable), and strips the CSRF cookie ($response->cookies->remove(csrfParam, false)). Stripping is critical: an emittedSet-Cookiedefeats the Cloudflare edge cache.CdnEdgeCache extends ActionFilter— headers only, no origin caching. Used bySearchController(Memcached fragmentation on free-form queries hurts more than it helps; Cloudflare's URL-based key is sufficient).CdnOriginAndEdgeCache extends yii\filters\PageCache— Yii origin page cache plus Cloudflare headers. Used byIndexController,CollectionController,NarratorController.
All cacheable routes share the tag sunnah, so one Cloudflare Purge by Tag (or purge_everything on non-Enterprise plans) invalidates the whole site.
When adding request-dependent behavior to origin-cached routes, account for Yii PageCache variations so cached output cannot bleed across request states.
POST endpoints (contact, ajaxhadithcount, flush-cache, selection-data, narrator AJAX list/cluster routes) must be listed in each controller's behaviors()['except'] array — otherwise the cache header logic will strip their CSRF cookie and break form submission.
Flow: SearchController::actionSearch → KeywordSearchEngine (dispatcher) → EnglishKeywordSearchEngine (only impl) → ElasticConnection::sendRequest("/english/search?q=…&size=…&from=…&collection=…&mode=…") → SearchResultset.
- ES hits carry the matched payload under
hit._source.en/hit._source.ar. Field names inside those payloads are the same asEnglishHadith/ArabicHadithproperty names — that's a cross-system contract maintained with the search indexer (search/main.pyin the sibling repo). Renaming model properties breaks search hydration. - Collection/book metadata for hydration still comes from
Util(DB-cached), not ES. - Suggestions ("did you mean") come from
hit.suggest.english[0].options(fallbacksuggest.arabic[0].options). - Query logging writes to
searchdb.search_queriesonly whenYII_ENV !== 'dev'.
Substantial newer module. Backed by Narrators, narrator_ahadith, clusters tables, plus FK to ArabicHadithTable.gk_hadith_id and Collections.gk_collection_id.
- All narrator SQL uses
MAX_EXECUTION_TIME(3000)hints — keep new queries within that budget. - AJAX endpoints:
/narrator/<nid>/hadith/list(partial render of_hadith_results) and/narrator/<nid>/hadith/cluster/<clusterId>(partial render of_hadith_cluster_links). Driven by inline JS registered inviews/narrator/hadith.php. - Arabic→English translation uses
narrator_maps.phpdictionaries with a character-level fallback (transliterateArabicName); structural particles (بن,أبو,بنت,مولى,ذو,أم,عبد ال…) are handled explicitly. - All narrator-page styles are scoped under
.narrator-pageinpublic/css/narrator.css— keep them scoped, and do not add.narrator-pagerules from elsewhere. - Material Symbols and Newsreader webfonts load only when
_pageType === "narrator"(seeviews/layouts/main.php).
Environment config is in .env.local (copy from .env.local.sample). It's an INI file parsed in application/config/main.php and public/index.php. Key vars:
yiiPath— path to the directory containingvendor/yiisoft/yii2/. Bothpublic/index.php(autoload) andmain.php(vendor path) read this. Wrong value = blank 500. Yii is loaded from$yiiPath/vendor, not the project's ownvendor/.MYSQL_HOST/MYSQL_DATABASE/MYSQL_USER/MYSQL_PASSWORD— maindbconnection.searchdb_*— search-log connection.elastic_host/elastic_port/solr_username/solr_password— ES connection (thesolr_*names are historical).cacheHost/cachePort/cacheTTL/searchCacheTTL— Memcached for prod (dev usesDummyCache).cookieValidationKey— required by Yii for cookie validation.recaptcha_v3_site_key/recaptcha_v3_secret_key(used by Yii contact form) andrecaptcha_private_key(used byprocesser.php, v2).smtpServer/smtpUser/smtpPassword/smtpPort— outbound mail.classy_campaign_id— donation widget on/donate.showCarousel—ramadan|dhulhijjah|ashurato enable the homepage seasonal carousel.
cp .env.local.sample .env.local
docker-compose up --builddb/0[0-3]*.sql are mounted into the MySQL container's /docker-entrypoint-initdb.d/ and run on first boot. Access at http://localhost.
Requires PHP 8.3+ (Dockerfile uses 8.5; composer.json minimum is >=8.3.0), IIS, MySQL, Composer. Run composer install in the project root. Point IIS to public/ with index.php as default document and a .php handler mapping.
- Add a public page — add a URL rule in
application/config/main.php(above the catch-all<collectionName:\w+>and<collectionName:.*>), add the action method to the appropriate controller (IndexControllerfor sitewide pages), add a view inviews/<controller>/<action>.php. If GET-only, leaveCdnOriginAndEdgeCacheto handle it; if POST, add the action ID to the filter'sexceptlist. - Add a hadith collection — INSERT into
Collections, populateBookData, populateEnglishHadithTable+ArabicHadithTable(+matchtablefor cross-refs), and add any special-case routes if the URL pattern is non-standard (see Gotchas). - Add a translation language — see "Hadith model hierarchy" above.
- Flush all caches —
/yiiadmin/flushcache(Yii cache only) + Cloudflare Purge by Tagsunnah(orpurge_everything). - Inspect 404s — separate log file
runtime/logs/404.log(not the mainapp.log). - Inspect hadith-count mismatch warnings —
runtime/logs/hadithcount.log(posted fromviews/collection/dispbook.phpvia/ajax/log/hadithcount).
php-cs-fixer is mentioned in the README but no .php-cs-fixer.dist.php is checked in — formatting is currently aspirational/manual. Match the surrounding style (tabs in older files, 4-space in newer narrator code).
There is no working test runner in this repo:
.travis.ymlstill references PHP 5.6 and anapplication/tests/phpunit.xmlthat no longer exists.composer.jsonlists Codeception inrequire-dev, butapplication/tests/is absent.
The global "every code change must have a unit test" rule (in dev-repo AGENTS.global.md) cannot be honored here until Codeception is scaffolded. When making a change:
- At minimum run
php -lon changed PHP files when possible. - Test manually in the Docker container (
docker-compose up --build) and exercise the affected routes. - Note the gap in the PR description.
- If introducing significant new logic (e.g. another
Utilhelper, a new search engine, more narrator translation), strongly consider adding a Codeception unit suite at the same time.
- All user-facing routes live in
application/config/main.php. Order matters — see Gotchas. - Hadith permalinks use the colon URL pattern
<collectionName>:<hadithNumber>(e.g./bukhari:1) for verified books;/urn/<n>is the URN-based fallback. - Per-language hadith models follow the
XYZHadith extends Hadithpattern withtableName() === '{{XYZHadithTable}}'. - Cache keys follow
<scope>:<identifier>(e.g.collection:bukhari,arabicurn:104350,narrator:hadith_clusters:v2:narrator:7:collections:1-2:links:7:clusters:30:offset:0). Bump thev<n>suffix when the cached value shape changes. - Controllers commonly set
$this->view->params['_pageType'], breadcrumbs via$this->pathCrumbs(...), and optional metadata such as_ogDesc,book,collection, andlastUpdated; keep these values current when adding pages or display modes. - View partials live next to their parent view and are prefixed with
_(e.g._hero.php,_hadith_results.php,_hadith_cluster_links.php). Use$this->renderPartial(...)for AJAX endpoints. - Views output a mix of trusted stored HTML and escaped user-facing values. Escape new dynamic values with
htmlspecialchars()or Yii helpers unless the value is intentionally pre-rendered HTML. - Prefer parameter binding, Yii query builders, or ActiveRecord conditions for new database access. Existing raw SQL exists mostly in utility/cache paths; do not expand it casually.
- CSS asset cache-busting: wrap CSS/JS paths in
$this->context->auto_version(...)— appends?ver=<mtime>for files underDOCUMENT_ROOT(no-op inYII_DEBUGmode or for non-rooted paths).
Loaded on most pages:
- Google Analytics (
G-PD11DFYVJC). - PostHog (
views/layouts/_posthog.php) — session recording enabled withmaskAllInputs: false. Privacy-sensitive; do not log secrets, account state, or anything else you wouldn't want replayed. - StatCounter (project
7148282). - jQuery 3.5.1, jQuery UI 1.12.1, Font Awesome 6.5.1.
- Classy donation widget (only on
/donate). - Google reCAPTCHA: v3 for the Yii
ContactForm(kekaadrenalin/yii2-module-recaptcha-v3), v2 for the barepublic/report.php→processer.phpflow.
- Route ordering — the catch-alls
<collectionName:\w+>and<collectionName:.*>are at the bottom of therulesarray inmain.phpand will swallow anything appended below them. New routes go above the catch-alls. - Special-cased collections —
nawawi40,qudsi40,shahwaliullah40all rewrite tocollectionName=fortywith differentourBookIDs;hisnandvirtuesignoreourBookID;nasai/35bandshamail/8buse negativeourBookID(-35,-8);<collection>/introductionusesourBookID = -1. Don't accidentally shadow these. - Legacy in-controller redirects —
CollectionController::handleOldRiyadussalihinLinks()issues 301s for the old Riyadus-Salihin book numbering. New legacy mappings should follow that pattern. - CSRF cookie strip —
CdnHeaders::attach()removes the CSRF cookie on cacheable 200/301 responses. Any new POST endpoint must be in theexceptlist of its controller'sbehaviors()['class' => 'app\components\Cdn…']block, otherwise its form submission breaks. - Bare-PHP public files —
public/share.php,report.php,processer.php,search_redirect.phpdo not go through Yii. Theyparse_ini_file('.env.local')directly, usemysqli_*and the unmaintained PEARMailpackage, and have hand-rolled IP / input handling. Prefer adding new endpoints as Yii controller actions. If you must touch these files, double-check input sanitization and don't introduce new dependencies on them. auto_version()is path-sensitive — only rewrites paths that start with/AND exist under$_SERVER['DOCUMENT_ROOT']. Relative or external URLs pass through unchanged. Don't rely on it for asset versioning in absolute-URL contexts.$yiiPathmismatch = blank 500 — Yii is loaded from$yiiPath/vendor, not the project's localvendor/. Aftercomposer installyou still need a separate Yii install referenced by.env.local.- Narrator CSS scope —
public/css/narrator.cssis namespaced under.narrator-page. Don't add narrator-page rules from other CSS files, and don't add.narrator-pageclass to non-narrator templates (it pulls in heavy custom-property overrides and dark-mode redefinitions). .travis.ymlis stale (PHP 5.6, missing tests path) — don't trust it as a build reference.composer.lockmay pin different versions thancomposer.jsondescribes —composer installreproduces the lock; only runcomposer updatewhen intentionally bumping deps.
The sibling search repo indexes hadith into Elasticsearch with _source documents whose keys mirror EnglishHadith / ArabicHadith property names. Renaming or removing a property here without coordinating with search/main.py's SELECT will silently break search-result rendering (records will be skipped with a Yii::warning("Search hit [...] missing _source....")). Any model-shape change should be paired with a search reindex.