Svaki projekat donosi nove izazove. WESTAL CMS — kompletan web sistem za Home & Garden centar u Bihaću — nije bio izuzetak. Shared hosting, PHP 7.4, bez root pristupa, bez Composera. Evo pet problema koji su mi oduzeli najviše vremena i kako sam ih riješio.


1. Session se gubi između www i non-www

Problem

Korisnik se prijavi na westal.ba, session se kreira. Browser ga preusmjeri na www.westal.ba — i session nestaje. Admin panel vraća 404, kupac je odjavljen.

Dijagnoza je bila jasna tek kad sam pogledao cookie u DevToolsu: session cookie bio je postavljen za westal.ba ali browser je bio na www.westal.ba. Dvije različite domene — dva različita session cookija.

Rješenje

ini_set('session.cookie_domain', '.westal.ba');
ini_set('session.cookie_secure',   '1');
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.cookie_httponly', '1');

Tačka ispred .westal.ba je ključna — govori browseru da cookie vrijedi za sve subdomene, uključujući i www. Zajedno s www → non-www redirectom u .htaccess kao prvim pravilom (redoslijed je bitan!), problem je potpuno riješen.

Naučena lekcija: Cookie domain i redirect moraju biti konzistentni. Postavljanje session.cookie_domain treba biti u bootstrap.php prije svakog session_start().


2. PHP 7.4 — neočekivani sintaksni problemi

Problem

Shared hosting na PHP 7.4. Kod koji lokalno radi uredno (PHP 8.2) na produkciji baca parse error:

PHP Parse error: syntax error, unexpected '=>'

Razlog: koristio sam match() izraz koji je uveden u PHP 8.0.

Slični problemi pojavili su se s str_starts_with(), str_ends_with(), string|int union type hintovima, fn() => arrow funkcijama unutar stringova i IF NOT EXISTS na ALTER TABLE u MySQL-u.

Rješenja

// PHP 8.0 match() → PHP 7.4 if/elseif
// Umjesto:
$result = match($sort) {
    'desc' => "ORDER BY date DESC",
    'asc'  => "ORDER BY date ASC",
    default => "ORDER BY date DESC",
};

// Koristiti:
if ($sort === 'desc') {
    $result = "ORDER BY date DESC";
} elseif ($sort === 'asc') {
    $result = "ORDER BY date ASC";
} else {
    $result = "ORDER BY date DESC";
}

// str_starts_with() → strpos()
if (strpos($string, 'prefix') === 0) { ... }

// str_ends_with() → substr()
if (substr($string, -4) === '.sql') { ... }

Za MySQL, ALTER TABLE ... ADD COLUMN IF NOT EXISTS nije podržano u starijim verzijama MariaDB. Rješenje je pokretanje svakog ALTER TABLE posebno i ignorisanje #1060 Duplicate column name greške.

Naučena lekcija: Uvijek testirajte na ciljnoj PHP verziji. Ako ne možete, provjerite PHP Compatibility Checker.


3. AJAX fetch blokiran CORS policy-jem

Problem

Admin panel radi AJAX pozive za detalje narudžbi i promjenu statusa. U konzoli se pojavljuje:

Access to fetch at 'https://www.westal.ba/admin/pages/narudzbe.php'
from origin 'https://westal.ba' has been blocked by CORS policy

BASE_URL je https://westal.ba, ali browser pristupa kroz https://www.westal.ba. Apsolutni URL u fetch() pozivima kreira cross-origin zahtjev.

Rješenje

Koristiti relativne URL-ove za interne AJAX pozive:

// Pogrešno — apsolutni URL može biti drugačiji domain od browsera
fetch('https://westal.ba/admin/pages/narudzbe.php', { ... })

// Ispravno — relativni URL uvijek koristi isti origin
fetch('narudzbe.php', { ... })

Za javni search endpoint koji se poziva s javnih stranica (gdje www može biti drugačiji od PHP BASE_URL), dodati Access-Control-Allow-Origin header:

header('Access-Control-Allow-Origin: *');

Naučena lekcija: Za interne admin AJAX pozive uvijek koristiti relativne URL-ove. Apsolutne koristiti samo kada su nužni i paziti na konzistentnost domena.


4. <script> unutar innerHTML se ne izvršava

Problem

AJAX poziv vraća HTML s <script> tagom koji sadrži funkciju. Modal prikazuje HTML, ali funkcija nije dostupna:

Uncaught ReferenceError: promijeniStatusModal is not defined

Browser ne izvršava <script> tagove koji su ubačeni kroz innerHTML — ovo je namjerno sigurnosno ograničenje.

Isti problem nastao je s dodajSpecRed() funkcijom koja je koristila innerHTML s navodnicima unutar navodnika:

// Lomi sintaksu — single quote unutar single-quote stringa
div.innerHTML = '...<button onclick="this.closest('.spec-red').remove()">...'

Rješenja

Funkcije koje se pozivaju iz dinamički generiranog HTML-a moraju biti u glavnom <script> bloku stranice — ne u AJAX odgovoru:

// Funkcija u glavnom scriptu stranice — uvijek dostupna globalno
function promijeniStatusModal(id, status, btn) { ... }

Za dinamičko kreiranje elemenata s event handlerima koristiti createElement umjesto innerHTML:

// Umjesto innerHTML s miješanim navodnicima:
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '✕';
btn.onclick = function() { this.closest('.spec-red').remove(); };
div.appendChild(btn);

Naučena lekcija: Nikad ne stavljati funkcije koje trebaju biti globalno dostupne u AJAX odgovore. createElement je pouzdaniji od innerHTML kada imate event handlere.


5. .htaccess redoslijed — neprimjetna greška koja lomi sve URL-ove

Problem

URL rewrite funkcioniše za neke stranice, ali /shop/naziv-artikla uvijek vraća 404. Direktan pristup shop-detalj.php?slug=test radi normalno.

Uzrok: www → non-www redirect pravilo bilo je dodano na kraj .htaccess fajla, između rewrite pravila. Plus fizički shop/folder postojao je na serveru — Apache pronalazi folder i ne radi rewrite.

# POGREŠNO — redirect između rewrite pravila
RewriteRule ^vijesti/...
RewriteRule ^kategorije/...

# www redirect na krivom mjestu — prekida pravila ispod njega
RewriteCond %{HTTP_HOST} ^www\.westal\.ba$
RewriteRule ^ https://westal.ba%{REQUEST_URI} [R=301,L]

RewriteRule ^shop/...  ← nikad se ne izvršava

Rješenje

Ispravan redoslijed u .htaccess:

RewriteEngine On
RewriteBase /

# 1. PRVO — www → non-www redirect
RewriteCond %{HTTP_HOST} ^www\.westal\.ba$ [NC]
RewriteRule ^ https://westal.ba%{REQUEST_URI} [R=301,L]

# 2. Sigurnost

# 3. Slug rewrite BEZ RewriteCond !-f !-d
#    (ne provjeravati postoji li fizički fajl/folder)
RewriteRule ^shop/([a-z0-9-]+)/?$ shop-detalj.php?slug=$1 [QSA,L]

# 4. Statičke stranice S RewriteCond provjером
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^shop/?$ shop.php [QSA,L]

Fizički prazan shop/ folder obrisan s hostinga — Apache ga je tretirao kao pravi direktorij.

Naučena lekcija: www → non-www redirect uvijek staviti na prvo mjesto. Slug rewrite pravila ne trebaju RewriteCond !-f !-djer slugovi nikad ne odgovaraju fizičkim fajlovima. Praznite foldere čistiti s hostinga.


Zaključak

Svaki od ovih problema bio je nevidljiv u development okruženju i pojavilo se tek na produkciji. Zajednički imenitelj: razlika između lokalnog servera (PHP 8.2, clean domain, root pristup) i shared hostinga (PHP 7.4, www/non-www, bez CLI-a).

Dokumentiranje rješenja u toku razvoja uštedilo bi sate debugginga. Nadam se da ovaj zapis nekome skrati put do rješenja.