System szablonów PHPTAL jako widok w Zend Framework
Artykuł dodany: 28 maja 2009. Ostatnia modyfikacja: 05 listopada 2016.
Stopień trudności (1 - dla początkujących, 5 - dla ekspertów): 4
PHPTAL dla Zend Framework 3 i Zend Expressive
Poniższy artykuł opisuje kod dla frameworka Zenda w wersji 1. Przygotowałem dwa repozytoria które obsługują najnowsze projekty spod stajni Zenda.
- Zend Framework 3
- Zend Expressive
Wprowadzenie
PHPTAL to system szablonów wzorowany na pythonowym ZPT, w całości napisany w PHP5, znacznie lżejszy i wydajniejszy od innych projektów takich jak Smarty czy Open Power Template. Jego największą zaletą jest to, iż generuje poprawnie sformatowany (“well-formed”) dokument a w przypadku popełnienia przez nas błędu (złe zamknięcie tagów) wyświetla stosowną informację. Dodatkowo automatycznie zabezpiecza nas przed atakami XSS. Od pewnego czasu pieczę nad projektem sprawuje Polak, Kornel Lesiński, znany w sieci jako porneL tak więc możemy również liczyć na jego polskie wsparcie. Widać też wyraźny rozwój całego projektu który z każdym wydaniem jest jeszcze lepszy. Zend Framework zawiera w sobie komponent Zend_View jednak jego stosowanie jest średnio przyjemne ale również mało czytelne. W artykule pokażę jak zastąpić domyślny widok PHPTALem. Można również skorzystać z gotowego repozytorium zawierającego wszystkie niezbędne do pracy komponenty.
Dla potrzeb artykułu załóżmy konwencjonalną modułową strukturę katalogową.
/application
/config (opcjonalnie)
/app.config.ini #plik konfiguracji
/(module 1) #rzeczywistą nazwą niech będzie `default`
/config (opcjonalnie)
/controllers
/models
/views
/filters
/helpers
/scripts #tutaj umieszczamy pliki źródłowe, domyślnie z rozszerzeniem .xhtml
/user
/login.xhtml
/listusers.xml
/site.xhtml
/bootstrap.php #plik odpowiedzialny za startowanie aplikacji
/htdocs (dostępne z poziomu www)
/images
/scripts
/styles
index.php
/library
/Zend
/Phptal #do katalogu wgrywamy zawartość rozpakowanego archiwum/svn PHPTALa
/PHPTAL
/PHPTAL.php
/My
/Controller
/Plugin
/SetPhptalView.php #wczytanie PHPTALa jako plugin kontrolera
/View
Phptal.php
/Browser.php #opis w tekście
/tmp
/phptal_compiled #pliki przetworzone przez PHPTAL
Dla ułatwienia pliki źródłowe szablonów umieszczamy w katalogu sugerowanym również przez Zend_View. Biblioteki PHPTAL przegrywamy do katalogu /library/Phptal (struktura taka dla zachowania przejrzystości bibliotek). Z poziomu przeglądarki dostępny jest wyłącznie folder /htdocs, reszta poza ogólnie dostępną strukturą katalogową. /My/View/Phptal.php to główny plik odpowiedzialny za połączenie ZF z PHPTALem. Poniżej jego zawartość.
My/View/Phptal.php
<?php
/**
* PHPTAL as view in Zend Framework
* Extends Zend_View_Abstract
* NOTE: this class is supposed to work with Autoloader
* Original concept by Matthew Ratzloff
* http://framework.zend.com/wiki/display/ZFPROP/Zend_View_PhpTal+-+Matthew+Ratzloff
*
* @category My
* @package My_View
* @license http://framework.zend.com/license/new-bsd New BSD License
*/
class My_View_Phptal extends Zend_View_Abstract {
/**
* PHPTAL engine
*
* @var PHPTAL
*/
private $_engine = null;
/**
* Ignore HTML/XHTML comments on parsing
*
* @var bool
*/
private $_stripComments = false;
/**
* Config options
*
* @var array
*/
private $_configDirectives = array(
'compiledDir' => '../tmp/phptal_compiled/',
'compiledFilesExtension' => 'php',
'charset' => 'utf-8',
'gzipCompression' => false,
'gzipCompressionLevel' => 7
);
/**
* Context variables
*
* @var array
*/
private $_variables = array();
/**#@+
* MIME type constants
* Default: text/plain
* 1 - Automatic selection: application/xhtml+xml, text/html
* 2 - application/xhtml+xml
* 3 - text/html
* 4 - text/xml
*/
const MIME_XHTML_AUTO = 1;
const MIME_XHTML = 2;
const MIME_HTML = 3;
const MIME_XML = 4;
/**#@-*/
/**
* Constructor
*
* @param array $config
*/
public function __construct(array $config = array()) {
ini_set('include_path', ini_get('include_path').PATH_SEPARATOR.'../library/Phptal/');
$this->_engine = new PHPTAL();
$this->_loadConfig($config);
$this->_setCompiledDir();
$this->setEncoding();
$this->_engine->setPhpCodeExtension($this->_configDirectives['compiledFilesExtension']);
$this->_engine->set('helper', $this);
parent::__construct();
}
/**
* Return the template engine object
*
* @return PHPTAL
*/
public function getEngine() {
return $this->_engine;
}
/**
* Set template variables
*
* @param string $key
* @param mixed $value
*/
public function __set($key, $value) {
$this->assign($key, $value);
}
/**
* Get template variable
*
* @param string $key
* @return null|mixed
*/
public function __get($key) {
if ($this->__isset($key)) {
return $this->_variables[$key];
}
return null;
}
/**
* Check if template variable is set
*
* @param string $key
* @return bool
*/
public function __isset($key) {
return array_key_exists($key, $this->_variables) and ($key[0] != '_');
}
/**
* Unset template variable
*
* @param string $key
*/
public function __unset($key) {
if ($this->__isset($key)) {
unset($this->_variables[$key]);
}
}
/**
* Clone engine object
*/
public function __clone() {
$this->_engine = clone $this->_engine;
}
/**
* Assigns variables to the view script via differing strategies.
*
* Zend_View::assign('name', $value) assigns a variable called 'name'
* with the corresponding $value.
*
* Zend_View::assign($array) assigns the array keys as variable
* names (with the corresponding array values).
*
* @see __set()
* @param string|array The assignment strategy to use.
* @param mixed (Optional) If assigning a named variable, use this
* as the value.
* @return My_View_Phptal Fluent interface
* @throws Zend_View_Exception if $spec is neither a string nor an array,
* or if an attempt to set a private or protected member is detected
*/
public function assign($spec, $value = null) {
if (is_string($spec)) {
if ('_' == substr($spec, 0, 1)) {
throw new Zend_View_Exception('Setting private or protected class members is not allowed', $this);
}
$this->_variables[$spec] = $value;
} elseif (is_array($spec)) {
$error = false;
foreach ($spec as $key => $val) {
if ('_' == substr($key, 0, 1)) {
$error = true;
break;
}
$this->_variables[$key] = $val;
}
if ($error) {
throw new Zend_View_Exception('Setting private or protected class members is not allowed', $this);
}
} else {
throw new Zend_View_Exception('assign() expects a string or array, received ' . gettype($spec), $this);
}
return $this;
}
/**
* Get all context variables
*
* @return array
*/
public function getVars() {
return $this->_variables;
}
/**
* Clear all context variables
*/
public function clearVars() {
$this->_variables = array();
}
/**
* Set Content-Type header
*
* @param int $mimeType
* @param string $encoding
* @return My_View_Phptal
*/
public function setContentType($mimeType = null, $encoding = null){
if (null !== $encoding) {
$this->setEncoding($encoding);
}
$encoding = $this->getEncoding();
$frontController = Zend_Controller_Front::getInstance();
if (Zend_Registry::isRegistered('browser')) {
$browser = Zend_Registry::get('browser');
} else {
$browser = new My_Browser();
Zend_Registry::set('browser', $browser);
}
switch ($mimeType) {
case 1 :
if ($browser->detectXhtmlMime()) {
$contentType = 'application/xhtml+xml';
} else {
$contentType = 'text/html';
}
break;
case 2 :
$contentType = 'application/xhtml+xml';
break;
case 3 :
$contentType = 'text/html';
break;
case 4 :
$contentType = 'text/xml';
break;
default:
$contentType = 'text/plain';
break;
}
$frontController->getResponse()->setHeader('Content-Type', ''.$contentType.'; charset='.$encoding, true);
return $this;
}
/**
* Returns character encoding for output
*
* @return string Character encoding
*/
public function getEncoding() {
return $this->_engine->getEncoding();
}
/**
* Set character encoding for output.
* Must be executed before @see setContentType()
*
* @param string $encoding Character encoding
* @return My_View_Phptal
*/
public function setEncoding($encoding = null) {
$encoding = (is_null($encoding)) ? $this->_configDirectives['charset'] : $encoding;
$this->_engine->setEncoding($encoding);
return $this;
}
/**
* Get output mode (XHTML or XML)
*
* @return int Constant value of PHPTAL_XHTML or PHPTAL_XML
*/
public function getOutputMode() {
return $this->_engine->getOutputMode();
}
/**
* Set output mode (XHTML or XML)
*
* @param int $mode PHPTAL_XHTML or PHPTAL_XML
* @return My_View_Phptal
*/
public function setOutputMode($mode) {
$this->_engine->setOutputMode($mode);
return $this;
}
/**
* Get whether to ignore HTML comments when parsing
*
* @return bool
*/
public function getStripComments() {
return $this->_stripComments;
}
/**
* Ignore XML/XHTML comments on parsing
*
* @param bool $bool
* @return My_View_Phptal
*/
public function setStripComments($bool = true) {
$this->_engine->stripComments($bool);
$this->_stripComments = $bool;
return $this;
}
/**
* Flags whether to ignore intermediate php files and to
* reparse templates every time (if set to true).
* Don't use in production - this makes PHPTAL significantly slower.
*
* @param bool $bool Forced reparse state.
* @return My_View_Phptal
*/
public function setForceReparse($bool) {
$this->_engine->setForceReparse($bool);
return $this;
}
/**
* Get whether to force a reparse or not
*
* @return bool
*/
public function getForceReparse() {
return $this->_engine->getForceReparse();
}
/**
* Get the path to the PHP generated file
*
* @return string PHP generated file path
*/
public function getCodePath() {
return $this->_engine->getCodePath();
}
/**
* Get the extension used for PHP files
*
* @return string PHP extension
*/
public function getCodeExtension() {
$this->_engine->getPhpCodeExtension();
}
/**
* Load config options
*
* @param array $config
*/
private function _loadConfig($config){
if(!is_array($config)) {
throw new My_View_Exception('Error loading configuration (must be an array)');
}
foreach($config as $_optk=>$_optv){
if (!array_key_exists($_optk, $this->_configDirectives)) {
throw new My_View_Exception("Configuration: Unknown option `$_optk`");
}
$this->_configDirectives["$_optk"] = $_optv;
}
}
/**
* Set directory for compiled files
*/
private function _setCompiledDir(){
if (!is_dir($this->_configDirectives['compiledDir'])) {
throw new My_View_Exception('Specified option `compiledDir` must be a directory');
}
if(!is_writable($this->_configDirectives['compiledDir'])) {
throw new My_View_Exception('Compiled files directory must be writable');
}
$this->_engine->setPhpCodeDestination($this->_configDirectives['compiledDir']);
}
/**
* Assign all variables to the PHPTAL engine
*
* @param array $variables Variables to assign
*/
private function _assignAll(array $variables = array()) {
foreach ($variables as $key => $value) {
$this->_engine->set($key, $value);
}
}
/**
* @see Zend_View_Abstract
*/
protected function _run() {
$this->_engine->setTemplate(func_get_arg(0));
$this->_assignAll($this->_variables);
try {
if ($this->_configDirectives['gzipCompression'] &&
extension_loaded('zlib') &&
ini_get('zlib.output_compression') == 0 &&
stristr($_SERVER['HTTP_ACCEPT_ENCODING'], 'gzip')
) {
$frontController = Zend_Controller_Front::getInstance();
$frontController->getResponse()->setHeader('Content-Encoding', 'gzip', true);
$frontController->getResponse()->setHeader('Vary', 'Accept-Encoding', true);
$buffer = gzencode($this->_engine->execute(), $this->_configDirectives['gzipCompressionLevel'], FORCE_GZIP);
$frontController->getResponse()->setHeader('Content-Length', strlen($buffer), true);
echo $buffer;
unset ($buffer);
return;
}
echo $this->_engine->execute();
} catch (Zend_View_Exception $e) {
throw new Zend_View_Exception($e);
}
}
}
/**
* PHPTAL context modifier (helper:)
*
* @param string $src
* @param boolean $nothrow
* @return string
*/
function phptal_tales_helper($src, $nothrow) {
$src = 'helper->'.trim($src);
return PHPTAL_Php_Transformer::transform($src, '$ctx->');
}
Postanowiłem rozszerzyć klasę Zend_View_Abstract gdyż w ten sposób otrzymujemy pełną integrację z innymi komponentami jak Zend_Form. Sama implementacja Zend_View_Interface w niektórych przypadkach jest również bardzo korzystna ponieważ w mniejszym stopniu konsumuje zasoby. Drugiej metody nie będę omawiał ale sprowadza się głównie do przeniesienia kodu z metody _run() do render(). Należy również zadbać o wczytywanie helperów (metody add* z Zend_View_Abstract).
Klasa daje również możliwość gzipowanie zawartości i wysłanie odpowiednich nagłówków HTTP.
Do działania potrzebujemy jeszcze jednej klasy – /My/Browser.php z metody setContentType().
My/Browser.php
<?php
class My_Browser {
public $clientAcceptXhtml = false;
/**
* Check if client can accept MIME XHTML.
*
* @param void
* @return bool true if client has MIME XHTML support, false if not detected.
*/
public function detectXhtmlMime(){
$this->clientAcceptXhtml = false;
$sAccept = (isset($_SERVER['HTTP_ACCEPT'])) ? $_SERVER['HTTP_ACCEPT'] : '';
if (preg_match('/application\/xhtml\+xml(?![+a-z])(;q=(0\.\d{1,3}|[01]))?/i', $sAccept, $matches)){
$xhtmlQ = isset($matches[2])?($matches[2]+0.2):1;
if (preg_match('/text\/html(;q=(0\d{1,3}|[01]))s?/i', $sAccept, $matches)){
$htmlQ = isset($matches[2]) ? $matches[2] : 1;
$this->clientAcceptXhtml = ($xhtmlQ >= $htmlQ);
return $this->clientAcceptXhtml;
} else {
$this->clientAcceptXhtml = true;
return $this->clientAcceptXhtml;
}
}
return false;
}
}
Sprawdza ona czy przeglądarka obsługuje typ MIME application/xhtml+xml. Opera i Firefox tak, IE nie obsługuje w związku z czym otrzyma text/html. Jeśli nie rozumiesz o czym mówię przeczytaj mój wcześniejszy artykuł.
Następnym krokiem będzie wskazanie PHPTAL jako widok. Dokonamy tego poprzez rejestrację pluginu. Utwórz plik /library/My/Controller/Plugin/SetPhptalView.php:
My/Controller/Plugin/SetPhptalView.php
<?php
class My_Controller_Plugin_SetPhptalView extends Zend_Controller_Plugin_Abstract {
public function preDispatch(Zend_Controller_Request_Abstract $request) {
$config = Zend_Registry::get('config');
$frontController = Zend_Controller_Front::getInstance();
$dispatcher = $frontController->getDispatcher();
$moduleNameUnformatted = ($request->getModuleName()) ?: $dispatcher->getDefaultModule();
$moduleDir = $frontController->getModuleDirectory();
$moduleName = $dispatcher->formatModuleName($moduleNameUnformatted);
$view = new My_View_Phptal($config->phptal->toArray());
$view->setContentType(My_View_Phptal::MIME_HTML)
->addHelperPath($moduleDir.'/views/helpers', $moduleName.'_View_Helper');
$viewRenderer = Zend_Controller_Action_HelperBroker::getStaticHelper('ViewRenderer');
$viewRenderer->setView($view)
->setViewSuffix('xhtml');
}
}
W pluginie automatycznie rejestrujemy ścieżkę do naszych własnych helperów (…/views/helpers), ustawiamy rozszerzenie szablonów na xhtml, oraz wysyłamy do klienta odpowiednie nagłówki (zobacz stałe My_View_Phptal::MIME*).
Następnie w swoim pliku startowym (bootstrap) zarejestruj plugin:
Bootstrap.php
[...] $frontController = Zend_Controller_Front::getInstance(); $frontController->registerPlugin(new My_Controller_Plugin_SetPhptalView());
W zaprezentowanym kodzie wykorzystywana jest również konfiguracja zarejestrowana w Zend_Registry pod kluczem ‘config’. Przyjmijmy, że korzystamy z Zend_Config_Ini wówczas przyjmie ona postać:
/application/config/app.config.ini
[production] phptal.compiledDir = "../tmp/phptal_compiled/" phptal.compiledFilesExtension = "php" phptal.charset = "UTF-8" phptal.gzipCompression = true phptal.gzipCompressionLevel = 7
Bootstrap.php
[...]
$config = new Zend_Config_Ini('../application/config/app.config.ini', 'production');
Zend_Registry::set('config', $config);
Nota dla korzystających z Zend_Application
Zamiast rejestrować My_Controller_Plugin_SetPhptalView jako wtyczkę kontrolera należy stworzyć nowy obiekt – plugin zasobów (resource plugin).
My/Application/Resource/Phptal.php
<?php
class My_Application_Resource_Phptal
extends Zend_Application_Resource_ResourceAbstract {
/**
* View object
*
* @var My_View_Phptal
*/
protected $_view;
/**
* Defined by Zend_Application_Resource_Resource
*
* @return My_View_Phptal
*/
public function init() {
$view = $this->getView();
$viewRenderer = new Zend_Controller_Action_Helper_ViewRenderer();
$viewRenderer->setView($view)
->setViewSuffix('xhtml');
Zend_Controller_Action_HelperBroker::addHelper($viewRenderer);
return $view;
}
/**
* Retrieve view object
*
* @return My_View_Phptal
*/
public function getView() {
if (null === $this->_view) {
$frontController = $this->getBootstrap()->getResource('frontController');
$request = $frontController->getRequest();
$this->_view = new My_View_Phptal($this->getOptions());
$this->_view->setContentType(My_View_Phptal::MIME_HTML)
->addHelperPath('../application/modules/'.$request->getModuleName().'/views/helpers', $request->getModuleName().'_views_helpers_');
}
return $this->_view;
}
}
Autoloader (Zend/Application/Module/Autoloader.php) automatycznie rejestruje przestrzeń nazw ‘View_Helper’.
Klucze konfiguracyjne dostępne będą dla opcji
[production] resources.My_Application_Resource_Phptal.compiledDir = APPLICATION_PATH "/../tmp/phptal_compiled/" resources.My_Application_Resource_Phptal. [...]
Kontroler i plik szablonu
PHPTAL powinien być gotowy do użycia. Przetestujmy. Przyjmijmy że mamy kontroler “user”, akcję “login” oraz wykorzystujemy makra i sloty. Specjalnie daję taki przykład aby pokazać jak świetnie taka koncepcja sprawdza się w Zend Framework. I to bez wykorzystywania dodatkowego kodu php. W sieci można znaleźć przykłady pełnej integracji PHPTAL z Zend_Layout gdzie stosujemy zwykłą metodę szablon główny -> include jednak według mnie neguje to całą ideę PHPTALa.
Zazwyczaj strona posiada jakiś główny plik nazwijmy go /application/modules/default/views/scripts/site.xhtml.
/application/default/views/scripts/site.xhtml
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="pl" lang="pl"
xmlns:tal="http://xml.zope.org/namespaces/tal"
xmlns:metal="http://xml.zope.org/namespaces/metal"
metal:define-macro="html">
<head>
<tal:block metal:define-slot="head"/>
</head>
<body xml:lang="pl" lang="pl">
<div id="content"><tal:block metal:define-slot="content"/></div>
</body>
</html>
Przestrzenie nazw nie są wymagane ale dla poprawności dokumentu warto je dodawać (zostaną automatycznie usunięte z kodu wynikowego). W dokumencie widać również definicję makra “html” oraz slotów “head” i “content”. Wykorzystajmy je z naszym kontrolerem user/login. Utwórz plik /application/default/views/scripts/user/login.xhtml.
/application/default/views/scripts/user/login.xhtml
<tal:block metal:use-macro="../site.xhtml/html">
<tal:block metal:fill-slot="content">
<tal:block tal:content="structure form">form data</tal:block>
<span tal:on-error="string:No username defined here"
tal:content="user/name">the user name here</span>
<div id="some" tal:content="structure helper:action('helper-test', 'user', null)"></div>
</tal:block>
<tal:block metal:fill-slot="head">
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<link href="stylesheet.css" rel="stylesheet" type="text/css"/>
</tal:block>
</tal:block>
Plik wypełniłem odrobinę losową zawartością aby zaprezentować łączenie z ZF. Zwróć uwagę na konstrukcję helper:action… W tym wypadku jest to mapowanie do helpera Zend_View_Helper_Action aczkolwiek możesz w ten sposób odwoływać się do dowolnych wbudowanych pluginów. Dodatkowo została zarejestrowana ścieżka …/views/helpers w którym to katalogu możesz umieszczać własne klasy. Formularz generujemy poprzez Zend_Form tak więc niezbędne jest użycie słowa “structure” aby PHPTAL wyświetlił kod HTML. Po kodzie widać, że jeżeli tylko chcesz możesz w zależności od kontrolera/akcji wczytywać dowolny plik główny, lub podstawiać różne dane do głównego szablonu. Daje to ogromne możliwości manipulacji dokumentem zwłaszcza w połączeniu z tal:condition.
Dane formularza oraz helper wypełniamy w kontrolerze.
/application/default/controllers/UserController.php
<?php
class UserController extends Zend_Controller_Action {
public function loginAction(){
//code from Zend_Form docs
$form = new Zend_Form();
$form->setAction('/user/login')
->setMethod('post');
// Create and configure username element:
$username = $form->createElement('text', 'username');
$username->addValidator('alnum')
->addValidator('regex', false, array('/^[a-z]+/'))
->addValidator('stringLength', false, array(6, 20))
->setRequired(true)
->addFilter('StringToLower');
// Create and configure password element:
$password = $form->createElement('password', 'password');
$password->addValidator('StringLength', false, array(6))
->setRequired(true);
// Add elements to form:
$form->addElement($username)
->addElement($password)
// use addElement() as a factory to create 'Login' button:
->addElement('submit', 'login', array('label' => 'Login'));
$this->view->form = $form;
// some todo code
}
public function helperTestAction() {
$this->_helper->viewRenderer->setNoRender();
echo 'some test data';
}
Jeżeli formularz został utworzony poprawnie oraz zobaczyłeś tekst ‘some test data’ wszystko działa jak należy.
Zobaczmy jeszcze jak łatwe jest tworzenie plików XML. Do kontrolera możemy dopisać przykładową akcję “listusers”.
/application/default/controllers/UserController.php
public function listusersAction() {
$this->view->users = $exampleDbTableUsers->fetchAll();
$this->_helper->viewRenderer->setViewSuffix('xml');
$this->view->setContentType(My_View_Phptal::MIME_XML);
$this->view->setOutputMode(PHPTAL::XML);
}
Przekazujemy do PHPTALa zmienną “users” zawierającą jakieś dane o użytkownikach pobrane z bazy. Zmieniamy też rozszerzenie plików na .xml oraz wysyłane nagłówki na ‘text/xml’ (MIME_XML). Tworzymy plik widoku:
/application/default/views/scripts/user/listusers.xml
<?xml version="1.0" encoding="UTF-8"?>
<users>
<user tal:repeat="user users">
<id>${user/id}</id>
<name tal:content="string:text+ ${user/name}"></name>
<rank tal:content="user/rank"></rank>
</user>
</users>
Plik jest bardzo czytelny (zaprezentowałem trzy różne metody odwoływania do zmiennych), możemy go podejrzeć w przeglądarce jako typowy xml. To kolejna zaleta PHPTALa.
Podsumowanie
PHPTAL to potężne narzędzie, do tego bardzo elastyczne i mam nadzieję że artykuł ten zachęci niezdecydowanych przynajmniej do pierwszych testów. Jako kontynuację zapraszam do lektury dokumentacji PHPTALa.
Dodaj komentarz
- php
- programowanie
- jquery
- sieć
- http
- javascript
- html
- mysql
- kodowanie
- xml
- xhtml
- postgresql
- css
- bazy danych
- internet
- zend framework
- zend framework 3
- apache
- sqlite
- phptal
Komentarze
Nie ma jeszcze żadnych komentarzy do wyświetlenia. Może chcesz zostać pierwszą osobą która podzieli się swoją opinią?