├── .travis.yml
├── Tests
├── bootstrap.php
└── Twig
│ └── TemplateLoaderTest.php
├── JmABBundle.php
├── phpunit.xml.dist
├── composer.json
├── Entity
├── TemplateRepository.php
├── TemplateManager.php
└── Template.php
├── DependencyInjection
├── Configuration.php
├── Compiler
│ └── TwigPass.php
└── JmABExtension.php
├── README.markdown
├── Resources
├── config
│ └── services.xml
└── doc
│ └── index.md
└── Twig
└── TemplateLoader.php
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: php
2 |
3 | php:
4 | - 5.3
5 |
6 | before_script:
7 | - composer install --dev
8 |
9 | script: phpunit
10 |
--------------------------------------------------------------------------------
/Tests/bootstrap.php:
--------------------------------------------------------------------------------
1 | addCompilerPass(new TwigPass(), PassConfig::TYPE_REMOVE);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | ./Tests
8 |
9 |
10 |
11 |
12 |
13 | ./
14 |
15 | ./Resources
16 | ./Tests
17 | ./vendor
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jm/ab-bundle",
3 | "type": "symfony-bundle",
4 | "description": "Simple way to manage multi-versions of templates (for AB Testing)",
5 | "keywords": ["AB Testing", "template", "twig"],
6 | "license": "MIT",
7 | "authors": [
8 | {
9 | "name": "Jeremy Marc",
10 | "email": "jeremy.marc@me.com"
11 | }
12 | ],
13 | "require": {
14 | "php": ">=5.3.2",
15 | "twig/twig": ">=1.7",
16 | "symfony/dependency-injection": ">=2.0",
17 | "symfony/http-foundation": "*"
18 | },
19 | "target-dir": "Jm/ABBundle",
20 | "autoload": {
21 | "psr-0": { "Jm\\ABBundle": "" }
22 | }
23 | }
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/Entity/TemplateRepository.php:
--------------------------------------------------------------------------------
1 | createQueryBuilder('t')
20 | ->where('t.name = :name')
21 | ->setParameter('name', $name)
22 | ->getQuery()
23 | ->useResultCache(true, $cacheTime)
24 | ;
25 |
26 | $template = $query->getSingleResult();
27 | } catch (NoResultException $e) {
28 | $template = null;
29 | }
30 |
31 | return $template;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/DependencyInjection/Configuration.php:
--------------------------------------------------------------------------------
1 | root('jm_ab');
22 |
23 | $rootNode->children()
24 | ->scalarNode('variation')->defaultValue('b')->end()
25 | ->booleanNode('custom_loader')
26 | ->defaultTrue()
27 | ->end()
28 | ->scalarNode('cache_time')->defaultValue(3600)->end()
29 | ;
30 |
31 | return $treeBuilder;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/README.markdown:
--------------------------------------------------------------------------------
1 | JmABBundle
2 | ==========
3 |
4 | The symfony2 ABBundle provide an easy way to manage application templates which
5 | can be used for AB Testing (or not).
6 |
7 | Features include:
8 |
9 | - Manage Template
10 | - Load Template from DB using Twig Loader
11 | - Use a custom variation parameter to switch between the 2 versions (A/B) of one template
12 |
13 | Documentation
14 | -------------
15 |
16 | The bulk of the documentation is stored in the `Resources/doc/index.md`
17 | file in this bundle:
18 |
19 | [Read the Documentation for master](https://github.com/jeremymarc/JmABBundle/blob/master/Resources/doc/index.md)
20 |
21 | Installation
22 | ------------
23 | [](https://travis-ci.org/jeremymarc/JmABBundle)
24 |
25 | All the installation instructions are located in [documentation](https://github.com/jeremymarc/JmABBundle/blob/master/Resources/doc/index.md).
26 |
27 |
28 | Reporting an issue or a feature request
29 | ---------------------------------------
30 |
31 | Issues and feature requests are tracked in the [Github issue tracker](https://github.com/jeremymarc/JmABBundle/issues).
32 |
33 |
--------------------------------------------------------------------------------
/DependencyInjection/Compiler/TwigPass.php:
--------------------------------------------------------------------------------
1 | getParameter('jm_ab.custom_loader')) {
13 | $loader = $container->getDefinition('twig.loader');
14 |
15 | $class = $loader->getClass();
16 | if (preg_match("/%(.*)%/", $class, $m)) {
17 | $class = $container->getParameter($m[1]);
18 | }
19 |
20 | if ("Twig\Loader\Chain" === $class) {
21 | $loader->addMethodCall('addLoader', array($container->getDefinition('jm_ab.template_loader')));
22 | return;
23 | }
24 |
25 | $abTwigLoader = $container->getDefinition('jm_ab.twig_loader');
26 | $abTwigLoader->addMethodCall('addLoader', array($loader));
27 | $container->setDefinition('twig.loader', $abTwigLoader);
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/DependencyInjection/JmABExtension.php:
--------------------------------------------------------------------------------
1 | processConfiguration($configuration, $configs);
25 |
26 | $container->setParameter('jm_ab.variation', $config['variation']);
27 | $container->setParameter('jm_ab.custom_loader', $config['custom_loader']);
28 | $container->setParameter('jm_ab.cache_time', $config['cache_time']);
29 |
30 | $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
31 | $loader->load('services.xml');
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/Resources/config/services.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
8 | b
9 | Jm\ABBundle\Entity\Template
10 | Jm\ABBundle\Entity\TemplateManager
11 | Twig_Loader_Chain
12 |
13 |
14 |
15 |
16 |
17 | %jm_ab.template.class%
18 |
19 | %jm_ab.cache_time%
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/Entity/TemplateManager.php:
--------------------------------------------------------------------------------
1 | em = $em;
21 | $this->repository = $em->getRepository($class);
22 | $this->class = $class;
23 | $this->container = $container;
24 | $this->cacheTime = $cacheTime;
25 |
26 | $this->cache = array();
27 | }
28 |
29 | public function getTemplate($name)
30 | {
31 | $name = trim($name);
32 | if (isset($this->cache[$name])) {
33 | return $this->cache[$name];
34 | }
35 |
36 | $template = $this->findTemplateByName($name);
37 |
38 | return $this->cache[$name] = $template;
39 | }
40 |
41 | public function renderTemplate($templateName, $vars = array())
42 | {
43 | if (0 !== strpos($templateName, 'template:')) {
44 | $templateName = "template:$templateName";
45 | }
46 | return $this->container->get('twig')->render($templateName, $vars);
47 | }
48 |
49 | /*
50 | * Do not call findTemplateByName directly
51 | * Use getTemplate instead as it's adding a cache support
52 | */
53 | protected function findTemplateByName($name)
54 | {
55 | return $this->repository->findTemplateByName($name, $this->cacheTime);
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/Resources/doc/index.md:
--------------------------------------------------------------------------------
1 | Getting started with JmABBundle
2 | ===============================
3 |
4 | The symfony2 ABBundle provide an easy way to manage application templates which can be used for AB Testing (or not).
5 |
6 |
7 | ## Installation
8 |
9 | ### Step 1: Download JmABBundle using composer
10 |
11 | Add JmABBundle in your composer.json:
12 |
13 | ```js
14 | {
15 | "require": {
16 | "jm/ab-bundle": "*"
17 | }
18 | }
19 | ```
20 |
21 | Now tell composer to download the bundle by running the command:
22 |
23 | ``` bash
24 | $ php composer.phar update jm/ab-bundle
25 | ```
26 |
27 | Composer will install the bundle to your project's `vendor/jm` directory.
28 |
29 |
30 | ### Step 2: Enable the bundle
31 | ```php
32 | public function registerBundles()
33 | {
34 | $bundles = array(
35 | // ...
36 | new Jm\ABBundle\JmABBundle(),
37 | );
38 | }
39 | ```
40 |
41 | ### Step 3: Configuration
42 |
43 | Configure the bundle using the dic configuration file
44 | ```php
45 | jm_ab:
46 | custom_loader: true
47 | variation: b
48 | cache_time: 3600
49 | ```
50 |
51 | ##### custom_loader: (default value true)
52 | Allow the bundle to chain our custom Twig loader to the current loader
53 | (Twig_Loader_Chain). When enable, you can render a template using twig, doing :
54 | ```php
55 | $this->get('twig')->render('template:name');
56 | ```
57 | Or in a twig template :
58 | ```php
59 | - include "template:name"|raw
60 | ```
61 |
62 | You can disable the loader with custom_loader: false
63 |
64 | ##### variation: (default value b)
65 | This is the default value for the variation (version B) of the page.
66 | To switch from one version to another one, just use the variation parameter in the url :
67 | ```html
68 | http://url.com/?variation_parameter -> http://url.com/?b
69 | ```
70 |
71 | If you want to insert the Google Analytics Content Experiment script (for AB
72 | Testing), just insert the {{ GAexperimentScript }} variable in the template.
73 | It will be automatically replaced by the GA JavaScripts (only if you have
74 | specified the experiment code in the Template).
75 |
76 | You can load a Template from a controller using TemplateManager :
77 | ```php
78 | $this->get('jm_ab.template_manager')->getTemplate('name', $vars);
79 | ```
80 | or with custom_loader set to true :
81 | ```php
82 | $this->get('jm_ab.template_manager')->renderTemplate('name') or ;
83 | $this->get('jm_ab.template_manager')->renderTemplate('template:name',
84 | $vars);
85 | ```
86 | Note that 'template:' and $vars are optionals.
87 |
88 | ##### cache_time (default value 3600 / 1h)
89 |
90 | Value of the cache for Doctrine Result Cache. Default value is 1 hour.
91 | When the doctrine cache is reset, the twig template will be automatically
92 | refresh (it's based on the Template's updatedAt value).
93 |
94 |
--------------------------------------------------------------------------------
/Twig/TemplateLoader.php:
--------------------------------------------------------------------------------
1 | container = $container;
19 | }
20 |
21 | /**
22 | * Gets the source code of a template, given its name.
23 | *
24 | * @param string $name The name of the template to load
25 | *
26 | * @return string The template source code
27 | *
28 | * @throws Twig_Error_Loader When $name is not found
29 | */
30 | public function getSource($name)
31 | {
32 | $name = $this->parse($name);
33 | $template = $this->getTemplate($name);
34 | $source = $this->getTemplateVariation($template);
35 |
36 | return $source;
37 | }
38 |
39 | /**
40 | * Gets the cache key to use for the cache for a given template name.
41 | *
42 | * @param string $name The name of the template to load
43 | *
44 | * @return string The cache key
45 | *
46 | * @throws Twig_Error_Loader When $name is not found
47 | */
48 | public function getCacheKey($fullName)
49 | {
50 | $name = $this->parse($fullName);
51 | $template = $this->getTemplate($name);
52 |
53 | return
54 | __CLASS__
55 | . '#' . $name
56 | . '#' . ($this->container->get('request')->get($this->container->getParameter('jm_ab.variation_parameter')) === null ? 'A' : 'B')
57 | // force reload even if Twig has autoReload to false
58 | . '#' . $template->getUpdatedAt()->getTimestamp()
59 | ;
60 | }
61 |
62 | /**
63 | * Returns true if the template is still fresh.
64 | *
65 | * @param string $name The template name
66 | * @param timestamp $time The last modification time of the cached template
67 | *
68 | * @return Boolean true if the template is fresh, false otherwise
69 | *
70 | * @throws Twig_Error_Loader When $name is not found
71 | */
72 | public function isFresh($name, $time)
73 | {
74 | $name = $this->parse($name);
75 | $template = $this->getTemplate($name);
76 |
77 | return $template->getUpdatedAt()->getTimestamp() <= $time;
78 | }
79 |
80 | private function canHandle($name)
81 | {
82 | return 0 === strpos($name, 'template:');
83 | }
84 |
85 | private function parse($name)
86 | {
87 | if (!preg_match('#^template:(.*)$#', $name, $m)) {
88 | throw new \Twig_Error_Loader(sprintf("Unable to find template %s", $name));
89 | }
90 |
91 | return $m[1];
92 | }
93 |
94 | private function getTemplate($name)
95 | {
96 | if (!$template = $this->container->get('jm_ab.template_manager')->getTemplate($name)) {
97 | throw new \Twig_Error_Loader(sprintf("Unable to find template %s", $name));
98 | }
99 |
100 | return $template;
101 | }
102 |
103 | private function getTemplateVariation(Template $template)
104 | {
105 | $content = $template->getBody();
106 | if (null !== $this->container->get('request')->get($this->container->getParameter('jm_ab.variation_parameter')) && $this->isValidBody($template->getVariationBody())) {
107 | $content = $template->getVariationBody();
108 | }
109 |
110 | //replace {{GAexperimentScript}} by the GA script
111 | if ($template->getExperimentCode()) {
112 | $content = preg_replace('/{{( )?GAexperimentScript( )?}}/', $template->getAnalyticsScript(), $content);
113 | }
114 |
115 | return $content;
116 | }
117 |
118 | private function isValidBody($body)
119 | {
120 | return $body !== null && strlen(trim($body)) > 1;
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/Entity/Template.php:
--------------------------------------------------------------------------------
1 | id;
78 | }
79 |
80 | /**
81 | * Set name
82 | *
83 | * @param string $name
84 | * @return Page
85 | */
86 | public function setName($name)
87 | {
88 | $this->name = $name;
89 |
90 | return $this;
91 | }
92 |
93 | /**
94 | * Get name
95 | *
96 | * @return string
97 | */
98 | public function getName()
99 | {
100 | return $this->name;
101 | }
102 |
103 | /**
104 | * Set body
105 | *
106 | * @param string $body
107 | * @return Page
108 | */
109 | public function setBody($body)
110 | {
111 | $this->body = $body;
112 |
113 | return $this;
114 | }
115 |
116 | /**
117 | * Get body
118 | *
119 | * @return string
120 | */
121 | public function getBody()
122 | {
123 | return $this->body;
124 | }
125 |
126 | /**
127 | * Get variationBody.
128 | *
129 | * @return variationBody.
130 | */
131 | public function getVariationBody()
132 | {
133 | return $this->variationBody;
134 | }
135 |
136 | /**
137 | * Set variationBody.
138 | *
139 | * @param variationBody the value to set.
140 | */
141 | public function setVariationBody($variationBody)
142 | {
143 | $this->variationBody = $variationBody;
144 | }
145 |
146 | /**
147 | * Get experimentCode.
148 | *
149 | * @return experimentCode.
150 | */
151 | public function getExperimentCode()
152 | {
153 | return $this->experimentCode;
154 | }
155 |
156 | /**
157 | * Set experimentCode.
158 | *
159 | * @param experimentCode the value to set.
160 | */
161 | public function setExperimentCode($experimentCode)
162 | {
163 | $this->experimentCode = $experimentCode;
164 | }
165 |
166 | /**
167 | * Set createdAt
168 | *
169 | * @param \DateTime $createdAt
170 | * @return DateTime
171 | */
172 | public function setCreatedAt($createdAt)
173 | {
174 | $this->createdAt = $createdAt;
175 |
176 | return $this;
177 | }
178 |
179 | /**
180 | * Get creationAt
181 | *
182 | * @return \DateTime
183 | */
184 | public function getCreatedAt()
185 | {
186 | return $this->createdAt;
187 | }
188 |
189 | /**
190 | * Set updatedAt
191 | *
192 | * @param \DateTime $updatedAt
193 | * @return DateTime
194 | */
195 | public function setUpdatedAt($updatedAt)
196 | {
197 | $this->updatedAt = $updatedAt;
198 |
199 | return $this;
200 | }
201 |
202 | /**
203 | * Get updatedAt
204 | *
205 | * @return \DateTime
206 | */
207 | public function getUpdatedAt()
208 | {
209 | return $this->updatedAt;
210 | }
211 |
212 | public function __toString()
213 | {
214 | return $this->getName() ?: '';
215 | }
216 |
217 | /**
218 | * @ORM\PrePersist
219 | */
220 | public function beforePersist()
221 | {
222 | $this->setCreatedAt(new \DateTime());
223 | $this->setUpdatedAt(new \DateTime());
224 | }
225 |
226 | /**
227 | * @ORM\PreUpdate
228 | */
229 | public function beforeUpdate()
230 | {
231 | $this->setUpdatedAt(new \DateTime());
232 | }
233 |
234 | public function getAnalyticsScript()
235 | {
236 | if (null === $this->getExperimentCode() || strlen($this->getExperimentCode()) < 5) {
237 | return;
238 | }
239 |
240 | $template = <<
242 |
254 |
255 | EOF;
256 | return str_replace('%EXPERIMENT_CODE%', $this->getExperimentCode(), $template);
257 | }
258 |
259 | protected function isValidBody($body)
260 | {
261 | return (null !== $body && strlen(trim($body)) > 0);
262 | }
263 | }
264 |
--------------------------------------------------------------------------------
/Tests/Twig/TemplateLoaderTest.php:
--------------------------------------------------------------------------------
1 | container = $this->getContainer();
16 | $this->templateLoader = $this->getTemplateLoader($this->container);
17 | }
18 |
19 | /**
20 | * @expectedException Twig_Error_Loader
21 | */
22 | public function shouldGenerateTwigErrorLoaderException()
23 | {
24 | $name = 'invalide-template-name';
25 | $this->templateLoader->getSource($name);
26 | }
27 |
28 | public function testGetSource()
29 | {
30 | $name = 'template:valid';
31 | $variationParameter = 'b';
32 |
33 | $manager = $this->getTemplateManager();
34 | $request = $this->getRequest();
35 |
36 | $template = new Template;
37 | $template->setName('test')
38 | ->setBody('body1')
39 | ->setVariationBody('body2');
40 |
41 | $request->expects($this->once())
42 | ->method('get')
43 | ->with($variationParameter)
44 | ->will($this->returnValue(null))
45 | ;
46 |
47 | $this->container->expects($this->once())
48 | ->method('getParameter')
49 | ->with('jm_ab.variation_parameter')
50 | ->will($this->returnValue($variationParameter))
51 | ;
52 |
53 | $this->container->expects($this->exactly(2))
54 | ->method('get')
55 | ->with($this->logicalOr(
56 | $this->equalTo('jm_ab.template_manager'),
57 | $this->equalTo('request')
58 | ))
59 | ->will($this->returnCallback(
60 | function($param) use ($manager, $request) {
61 | if ('jm_ab.template_manager' == $param) {
62 | return $manager;
63 | }
64 |
65 | return $request;
66 | })
67 | )
68 | ;
69 |
70 | $manager->expects($this->once())
71 | ->method('getTemplate')
72 | ->with('valid')
73 | ->will($this->returnValue($template))
74 | ;
75 |
76 | $content = $this->templateLoader->getSource($name);
77 | $this->assertEquals($content, $template->getBody());
78 | }
79 |
80 | public function testGetSourceWithVariationBody()
81 | {
82 | $name = 'template:valid';
83 | $variationParameter = 'b';
84 |
85 | $manager = $this->getTemplateManager();
86 | $request = $this->getRequest();
87 |
88 | $template = new Template;
89 | $template->setName('test')
90 | ->setBody('body1')
91 | ->setVariationBody('body2');
92 |
93 | $request->expects($this->once())
94 | ->method('get')
95 | ->with($variationParameter)
96 | ->will($this->returnValue(1))
97 | ;
98 |
99 | $this->container->expects($this->once())
100 | ->method('getParameter')
101 | ->with('jm_ab.variation_parameter')
102 | ->will($this->returnValue($variationParameter))
103 | ;
104 |
105 | $this->container->expects($this->exactly(2))
106 | ->method('get')
107 | ->with($this->logicalOr(
108 | $this->equalTo('jm_ab.template_manager'),
109 | $this->equalTo('request')
110 | ))
111 | ->will($this->returnCallback(
112 | function($param) use ($manager, $request) {
113 | if ('jm_ab.template_manager' == $param) {
114 | return $manager;
115 | }
116 |
117 | return $request;
118 | })
119 | )
120 | ;
121 |
122 | $manager->expects($this->once())
123 | ->method('getTemplate')
124 | ->with('valid')
125 | ->will($this->returnValue($template))
126 | ;
127 |
128 | $content = $this->templateLoader->getSource($name);
129 | $this->assertEquals($content, $template->getVariationBody());
130 | }
131 |
132 | public function testGetCacheKey()
133 | {
134 | $name = 'test';
135 | $variationParameter = 'b';
136 | $manager = $this->getTemplateManager();
137 | $now = new \DateTime();
138 |
139 | $template = new Template;
140 | $template->setUpdatedAt($now);
141 |
142 | $request = $this->getRequest();
143 | $request->expects($this->once())
144 | ->method('get')
145 | ->with($variationParameter)
146 | ->will($this->returnValue(1))
147 | ;
148 |
149 | $this->container->expects($this->once())
150 | ->method('getParameter')
151 | ->with('jm_ab.variation_parameter')
152 | ->will($this->returnValue($variationParameter))
153 | ;
154 | $this->container->expects($this->exactly(2))
155 | ->method('get')
156 | ->with($this->logicalOr(
157 | $this->equalTo('jm_ab.template_manager'),
158 | $this->equalTo('request')
159 | ))
160 | ->will($this->returnCallback(
161 | function($param) use ($manager, $request) {
162 | if ('jm_ab.template_manager' == $param) {
163 | return $manager;
164 | }
165 |
166 | return $request;
167 | })
168 | )
169 | ;
170 | $manager->expects($this->once())
171 | ->method('getTemplate')
172 | ->with($name)
173 | ->will($this->returnValue($template))
174 | ;
175 |
176 | $loader = $this->getTemplateLoader($this->container);
177 | $key = $loader->getCacheKey('template:'. $name);
178 | $expectedKey = 'Jm\ABBundle\Twig\TemplateLoader#test#B#' . $template->getUpdatedAt()->getTimestamp();
179 |
180 | $this->assertEquals($key, $expectedKey);
181 | }
182 |
183 | public function testIsFresh()
184 | {
185 | $name = 'test';
186 | $now = new \DateTime();
187 | $yesterday = new \DateTime(); //template update cached version is from yesterday
188 | $yesterday->modify('- 1 day');
189 |
190 | $template = new Template();
191 | $template->setUpdatedAt($now); //we've just modified the template
192 |
193 | $manager = $this->getTemplateManager();
194 | $manager->expects($this->once())
195 | ->method('getTemplate')
196 | ->with($name)
197 | ->will($this->returnValue($template))
198 | ;
199 |
200 | $this->container->expects($this->once())
201 | ->method('get')
202 | ->with('jm_ab.template_manager')
203 | ->will($this->returnValue($manager))
204 | ;
205 |
206 | $loader = $this->getTemplateLoader($this->container);
207 | $isFresh = $loader->isFresh('template:' . $name, $yesterday->getTimestamp());
208 | $this->assertFalse($isFresh);
209 | }
210 |
211 | private function getTemplateLoader($container)
212 | {
213 | return new TemplateLoader($container);
214 | }
215 |
216 | private function getTemplateManager()
217 | {
218 | return $this
219 | ->getMockBuilder('Jm\ABBundle\Entity\TemplateManager')
220 | ->disableOriginalConstructor()
221 | ->getMock()
222 | ;
223 | }
224 |
225 | private function getContainer()
226 | {
227 | return $this->getMock('Symfony\Component\DependencyInjection\ContainerInterface');
228 | }
229 |
230 | public function getRequest()
231 | {
232 | return $this->getMock('Symfony\Component\HttpFoundation\Request');
233 | }
234 | }
235 |
--------------------------------------------------------------------------------