Rename directories
BIN
src/deploying-drupal-ansible-ansistrano/demo.mp4
Normal file
After Width: | Height: | Size: 226 KiB |
After Width: | Height: | Size: 58 KiB |
BIN
src/deploying-drupal-ansible-ansistrano/images/ansible.png
Normal file
After Width: | Height: | Size: 145 KiB |
After Width: | Height: | Size: 44 KiB |
BIN
src/deploying-drupal-ansible-ansistrano/images/ansistrano.png
Normal file
After Width: | Height: | Size: 409 KiB |
BIN
src/deploying-drupal-ansible-ansistrano/images/composer.png
Normal file
After Width: | Height: | Size: 120 KiB |
After Width: | Height: | Size: 114 KiB |
After Width: | Height: | Size: 108 KiB |
After Width: | Height: | Size: 617 KiB |
BIN
src/deploying-drupal-ansible-ansistrano/images/druplicon.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
src/deploying-drupal-ansible-ansistrano/images/logo-acquia.png
Normal file
After Width: | Height: | Size: 76 KiB |
After Width: | Height: | Size: 48 KiB |
BIN
src/deploying-drupal-ansible-ansistrano/images/logo-linode.png
Normal file
After Width: | Height: | Size: 30 KiB |
BIN
src/deploying-drupal-ansible-ansistrano/images/logo-pantheon.png
Normal file
After Width: | Height: | Size: 33 KiB |
After Width: | Height: | Size: 7.3 KiB |
BIN
src/deploying-drupal-ansible-ansistrano/images/logo-vultr.png
Normal file
After Width: | Height: | Size: 5.8 KiB |
BIN
src/deploying-drupal-ansible-ansistrano/images/site.png
Normal file
After Width: | Height: | Size: 626 KiB |
BIN
src/deploying-drupal-ansible-ansistrano/images/vagrant.png
Normal file
After Width: | Height: | Size: 292 KiB |
948
src/deploying-drupal-ansible-ansistrano/slides.md
Normal file
|
@ -0,0 +1,948 @@
|
|||
autoscale: true
|
||||
build-lists: true
|
||||
code: line-height(1.2)
|
||||
header-emphasis: #53B0EB
|
||||
text: alignment(left)
|
||||
theme: simple, 8
|
||||
|
||||
# [fit] **Deploying PHP applications** <br>using Ansible, Ansible Vault <br>and Ansistrano
|
||||
|
||||
^ I work primarily with PHP, and there will be some PHP-isms in this talk (LAMP stack, Composer).
|
||||
Will be using a Drupal 8 application as the example, but the tools are tool and language agnostic.
|
||||
|
||||
---
|
||||
|
||||
# [fit] **Deploying ~~PHP~~ applications** <br>using Ansible, Ansible Vault <br>and Ansistrano
|
||||
|
||||
---
|
||||
|
||||
## **What we'll be looking at**
|
||||
|
||||
* **Ansible** crash course
|
||||
* Keeping secrets with **Ansible Vault**
|
||||
* Deployments with **Ansistrano**
|
||||
|
||||
---
|
||||
|
||||
[.build-lists: false]
|
||||
[.header: #111111]
|
||||
|
||||

|
||||
|
||||
- Full Stack Software Developer & System Administrator
|
||||
- **Senior Software Engineer** at **Inviqa**
|
||||
- Acquia certified **Drupal 8 Grand Master** and **Cloud Pro**
|
||||
- Open sourcer
|
||||
- Drupal 7 & 8 **core contributor**
|
||||
- @opdavies
|
||||
- www.oliverdavies.uk
|
||||
|
||||
^ Maintain Drupal modules, PHP CLI tools and libraries, Ansible roles
|
||||
Blog on my website
|
||||
I work primarily with Drupal and Symfony
|
||||
I work for Inviqa, but this based on my personal and side projects.
|
||||
I've been using Ansible for a number of years, initially only for provisioning and setting up my laptop, and later for application deployments
|
||||
|
||||
---
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
^ Large, well-known managed hosting companies
|
||||
Optimised servers for PHP/Drupal applications
|
||||
Include some sort of deployment system
|
||||
This workflow doesn't apply to this scenario
|
||||
|
||||
---
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
^ More applicable to virtual or dedicated servers with no existing deployment process
|
||||
Not enough budget for fully-managed, or using internal infrastructure
|
||||
This is where the this workflow would be useful
|
||||
|
||||
---
|
||||
|
||||
# **What is Ansible?**
|
||||
|
||||
---
|
||||
|
||||
## Ansible is an open-source **software provisioning, configuration management, and application-deployment** tool.
|
||||
|
||||

|
||||
|
||||
[.footer: https://en.wikipedia.org/wiki/Ansible_(software)]
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
### **What is Ansible?**
|
||||
|
||||
* CLI tool
|
||||
* Written in Python
|
||||
* Configured with YAML
|
||||
* Executes ad-hoc remote commands
|
||||
* Installs software packages
|
||||
* Performs deployment steps
|
||||
* Batteries included
|
||||
|
||||
^ Written in Python but you don't need to write or know Python to use it
|
||||
Drupal, Symfony and a lot of other projects use YAML
|
||||
First-party modules (SSH keys, file and directory management, package repositories, stopping/starting/restarting services, DO/Linode/AWS integration)
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
### **What is Ansible?**
|
||||
|
||||
* Hosts/Inventories
|
||||
* Commands
|
||||
* Playbooks
|
||||
* Tasks
|
||||
* Roles
|
||||
|
||||
^ Hosts: where your managed nodes/hosts are. Can be static or dynamic.
|
||||
Commands: run from a control node onto managed nodes
|
||||
Playbooks and Tasks: YAML representation of a series of commands/steps
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
### **Why Ansible?**
|
||||
|
||||
* Familiar syntax
|
||||
* Easily readable
|
||||
* No server dependencies
|
||||
* Easy to add to an existing project
|
||||
* Includes relevant modules (e.g. Composer)
|
||||
* Idempotency
|
||||
|
||||
^ Drupal 8, Symfony, Ansible all use YAML
|
||||
Runs on any server with Python
|
||||
Plugins into Drupal via CLI apps like Drush and Drupal Console
|
||||
Changes are only made when needed (once)
|
||||
|
||||
---
|
||||
|
||||
# **Hosts / Inventories**
|
||||
|
||||
---
|
||||
|
||||
```ini
|
||||
# hosts.ini
|
||||
|
||||
[webservers]
|
||||
192.168.33.10
|
||||
```
|
||||
|
||||
^ Supports wildcards and ranges.
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# hosts.yml
|
||||
|
||||
webservers:
|
||||
hosts:
|
||||
192.168.33.10:
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# **Commands**
|
||||
|
||||
---
|
||||
|
||||
# `ansible all -i hosts.yml -m ping`
|
||||
|
||||
---
|
||||
|
||||
```json
|
||||
webservers | SUCCESS => {
|
||||
"ansible_facts": {
|
||||
"discovered_interpreter_python": "/usr/bin/python"
|
||||
},
|
||||
"changed": false,
|
||||
"ping": "pong"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# `ansible all `<br>`-i hosts.yml `<br>`-m command `<br>`-a 'git pull `<br>`--chdir=/app'`
|
||||
|
||||
---
|
||||
|
||||
# `ansible all -i hosts.yml`<br>`-m git -a 'repo=https://github.com/opdavies/dransible dest=/app`'
|
||||
|
||||
---
|
||||
|
||||
# **Tasks and Playbooks**
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# playbook.yml
|
||||
---
|
||||
- hosts: webservers # or 'all'
|
||||
|
||||
vars:
|
||||
git_repo: https://github.com/opdavies/dransible
|
||||
|
||||
tasks:
|
||||
- name: Update the code
|
||||
git:
|
||||
repo: '{{ git_repo }}'
|
||||
dest: /app
|
||||
version: master
|
||||
update: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# `ansible-playbook `<br>`playbook.yml -i hosts.yml`
|
||||
|
||||
---
|
||||
|
||||
# **Roles: <br>configuring a LAMP stack**
|
||||
|
||||
^ Collections of tasks, variables and handlers
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# requirements.yml
|
||||
---
|
||||
- src: geerlingguy.apache
|
||||
- src: geerlingguy.composer
|
||||
- src: geerlingguy.mysql
|
||||
- src: geerlingguy.php
|
||||
- src: geerlingguy.php-mysql
|
||||
```
|
||||
|
||||
^ Provisioning LAMP stack and Composer
|
||||
|
||||
---
|
||||
|
||||
# `ansible-galaxy -r`<br>`requirements.yml install`
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# provision.yml
|
||||
---
|
||||
- hosts: webservers
|
||||
|
||||
roles:
|
||||
- geerlingguy.apache
|
||||
- geerlingguy.mysql
|
||||
- geerlingguy.php
|
||||
- geerlingguy.php-mysql
|
||||
- geerlingguy.composer
|
||||
```
|
||||
|
||||
^ Role order matters!
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# provision.yml
|
||||
---
|
||||
- hosts: webservers
|
||||
# ...
|
||||
|
||||
vars:
|
||||
apache_vhosts:
|
||||
- servername: dransible.wip
|
||||
documentroot: /app/web
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# provision.yml
|
||||
---
|
||||
- hosts: webservers
|
||||
# ...
|
||||
|
||||
vars:
|
||||
# ...
|
||||
php_version: '7.4'
|
||||
php_packages_extra:
|
||||
- libapache2-mod-php{{ php_version }}
|
||||
- libpcre3-dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# provision.yml
|
||||
---
|
||||
- hosts: webservers
|
||||
# ...
|
||||
|
||||
vars:
|
||||
# ...
|
||||
mysql_databases:
|
||||
- name: main
|
||||
|
||||
mysql_users:
|
||||
- name: user
|
||||
password: secret
|
||||
priv: main.*:ALL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# `ansible-playbook provision.yml -i hosts.yml`
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
PLAY [Provision the webserver machines] ********************************************************************************
|
||||
|
||||
TASK [Gathering Facts] *************************************************************************************************
|
||||
ok: [webservers]
|
||||
|
||||
TASK [geerlingguy.apache : Include OS-specific variables.] *************************************************************
|
||||
ok: [webservers]
|
||||
|
||||
TASK [geerlingguy.apache : Include variables for Amazon Linux.]
|
||||
skipping: [webservers]
|
||||
|
||||
TASK [geerlingguy.apache : Define apache_packages.] ********************************************************************
|
||||
ok: [webservers]
|
||||
|
||||
TASK [geerlingguy.apache : include_tasks] ******************************************************************************
|
||||
included: /Users/opdavies/.ansible/roles/geerlingguy.apache/tasks/setup-Debian.yml for webservers
|
||||
|
||||
TASK [geerlingguy.apache : Update apt cache.] **************************************************************************
|
||||
changed: [webservers]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
TASK [geerlingguy.composer : Ensure composer directory exists.] ********************************************************
|
||||
ok: [webservers]
|
||||
|
||||
TASK [geerlingguy.composer : include_tasks] ****************************************************************************
|
||||
skipping: [webservers]
|
||||
|
||||
TASK [geerlingguy.composer : include_tasks] ****************************************************************************
|
||||
skipping: [webservers]
|
||||
|
||||
RUNNING HANDLER [geerlingguy.apache : restart apache] ******************************************************************
|
||||
changed: [webservers]
|
||||
|
||||
RUNNING HANDLER [geerlingguy.mysql : restart mysql] ********************************************************************
|
||||
changed: [webservers]
|
||||
|
||||
RUNNING HANDLER [geerlingguy.php : restart webserver] ******************************************************************
|
||||
changed: [webservers]
|
||||
|
||||
RUNNING HANDLER [geerlingguy.php : restart php-fpm] ********************************************************************
|
||||
skipping: [webservers]
|
||||
|
||||
PLAY RECAP *************************************************************************************************************
|
||||
webservers : ok=111 changed=32 unreachable=0 failed=0 skipped=78 rescued=0 ignored=0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
# **Keeping secrets with <br>Ansible Vault**
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# provision.yml
|
||||
---
|
||||
- hosts: webservers
|
||||
# ...
|
||||
|
||||
vars:
|
||||
# ...
|
||||
mysql_databases:
|
||||
- name: main
|
||||
|
||||
mysql_users:
|
||||
- name: user
|
||||
password: secret
|
||||
priv: main.*:ALL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
[.code-highlight: 11-14]
|
||||
|
||||
```yaml
|
||||
# provision.yml
|
||||
---
|
||||
- hosts: webservers
|
||||
# ...
|
||||
|
||||
vars:
|
||||
# ...
|
||||
mysql_databases:
|
||||
- name: main
|
||||
|
||||
mysql_users:
|
||||
- name: user
|
||||
password: secret
|
||||
priv: main.*:ALL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# `ansible-vault create` <br>`vault.yml`
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# vars/vault.yml
|
||||
---
|
||||
vault_database_name: main
|
||||
vault_database_user: user
|
||||
vault_database_password: secret
|
||||
```
|
||||
|
||||
^ Optional, but easier to see where variables are set
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
36656233323539616336393838396137343939623233393338666530313
|
||||
73037323366326363306531336333353163643063663335396139363762
|
||||
39383133330a35636566623262353733373066363837393264616134613
|
||||
16366376266646437373366373738303931616333626332353839353332
|
||||
32663432346662613438330a38643539343232376138613733373636343
|
||||
63864666430313866623539333039363138646331326565386263386666
|
||||
35306264396230633939346532356665306564626431353936643135376
|
||||
23834346635366637613235656165643361316663396530383263333064
|
||||
33326264316235396431666262346637366563376330363238373331373
|
||||
43533386165366531626462643662666266316639306262666539373236
|
||||
343662313265376261316636623963353933613366353737363435
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# vars/vars.yml
|
||||
---
|
||||
database_name: "{{ vault_database_name }}"
|
||||
database_user: "{{ vault_database_user }}"
|
||||
database_password: "{{ vault_database_password }}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# provision.yml
|
||||
---
|
||||
mysql_databases:
|
||||
- '{{ database_name }}'
|
||||
|
||||
mysql_users:
|
||||
- name: '{{ database_user }}'
|
||||
password: '{{ database_password }}'
|
||||
priv: '{{ database_name }}.*:ALL'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# `ansible-vault edit vault.yml`
|
||||
|
||||
---
|
||||
|
||||
# `ansible-playbook` <br>`-i hosts.yml` <br>`deploy.yml`<br>`--ask-vault-pass`
|
||||
|
||||
---
|
||||
|
||||
# **Basic deployment**
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# deploy.yml
|
||||
|
||||
tasks:
|
||||
- name: Creating project directory
|
||||
file:
|
||||
path: /app
|
||||
state: directory
|
||||
|
||||
- name: Uploading application
|
||||
synchronize:
|
||||
src: "{{ playbook_dir }}/../"
|
||||
dest: /app
|
||||
|
||||
- name: Installing Composer dependencies
|
||||
composer:
|
||||
command: install
|
||||
working_dir: /app
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Disadvantages
|
||||
|
||||
* Single point of failure
|
||||
* No ability to roll back
|
||||
* Sensitive data stored in plain text
|
||||
|
||||
---
|
||||
|
||||
# **Better deployments**
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ Just another role, specifically for deployments
|
||||
Ansible port of Capistrano
|
||||
|
||||
---
|
||||
|
||||
# Features
|
||||
|
||||
* Multiple release directories
|
||||
* Shared paths and files
|
||||
* Customisable
|
||||
* Multiple deployment strategies
|
||||
* Multi-stage environments
|
||||
* Prune old releases
|
||||
* Rollbacks
|
||||
|
||||
^ rsync, Git, SVN etc
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# requirements.yml
|
||||
---
|
||||
# ...
|
||||
- ansistrano.deploy
|
||||
- ansistrano.rollback
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# deploy.yml
|
||||
|
||||
---
|
||||
- hosts: all
|
||||
|
||||
roles:
|
||||
- ansistrano.deploy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# deploy.yml
|
||||
---
|
||||
# ...
|
||||
vars:
|
||||
project_deploy_dir: /app
|
||||
|
||||
ansistrano_deploy_to: '{{ project_deploy_dir }}'
|
||||
ansistrano_deploy_via: git
|
||||
ansistrano_git_branch: master
|
||||
ansistrano_git_repo: 'git@github.com:opdavies/dransible'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# `ansible-playbook` <br>`-i hosts.yml` <br>`deploy.yml`
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
PLAY [webservers] ******************************************************************************************************
|
||||
|
||||
TASK [Gathering Facts] *************************************************************************************************
|
||||
ok: [webservers]
|
||||
|
||||
TASK [ansistrano.deploy : include_tasks] *******************************************************************************
|
||||
|
||||
TASK [ansistrano.deploy : include_tasks] *******************************************************************************
|
||||
included: /Users/opdavies/.ansible/roles/ansistrano.deploy/tasks/setup.yml for webservers
|
||||
|
||||
TASK [ansistrano.deploy : ANSISTRANO | Ensure deployment base path exists] *********************************************
|
||||
ok: [webservers]
|
||||
|
||||
TASK [ansistrano.deploy : ANSISTRANO | Ensure releases folder exists] **************************************************
|
||||
ok: [webservers]
|
||||
|
||||
TASK [ansistrano.deploy : ANSISTRANO | Ensure shared elements folder exists] *******************************************
|
||||
ok: [webservers]
|
||||
|
||||
TASK [ansistrano.deploy : ANSISTRANO | Ensure shared paths exists] *****************************************************
|
||||
ok: [webservers] => (item=web/sites/default/files)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
TASK [ansistrano.deploy : Update file permissions] *********************************************************************
|
||||
changed: [webservers]
|
||||
|
||||
TASK [ansistrano.deploy : include_tasks] *******************************************************************************
|
||||
|
||||
TASK [ansistrano.deploy : include_tasks] *******************************************************************************
|
||||
included: /Users/opdavies/.ansible/roles/ansistrano.deploy/tasks/cleanup.yml for webservers
|
||||
|
||||
TASK [ansistrano.deploy : ANSISTRANO | Clean up releases] **************************************************************
|
||||
changed: [webservers]
|
||||
|
||||
TASK [ansistrano.deploy : include_tasks] *******************************************************************************
|
||||
|
||||
TASK [ansistrano.deploy : include_tasks] *******************************************************************************
|
||||
included: /Users/opdavies/.ansible/roles/ansistrano.deploy/tasks/anon-stats.yml for webservers
|
||||
|
||||
TASK [ansistrano.deploy : ANSISTRANO | Send anonymous stats] ***********************************************************
|
||||
skipping: [webservers]
|
||||
|
||||
PLAY RECAP *************************************************************************************************************
|
||||
webservers : ok=33 changed=14 unreachable=0 failed=0 skipped=7 rescued=0 ignored=0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```bash
|
||||
vagrant@dransible:/app$ ls -l
|
||||
total 8
|
||||
|
||||
lrwxrwxrwx 1 26 Jul 19 00:15 current -> ./releases/20190719001241Z
|
||||
drwxr-xr-x 5 4096 Jul 22 20:30 releases
|
||||
drwxr-xr-x 4 4096 Jul 19 00:00 shared
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
vagrant@dransible:/app/releases$ ls -l
|
||||
total 20
|
||||
|
||||
drwxr-xr-x 5 4096 Jul 22 20:30 .
|
||||
drwxr-xr-x 4 4096 Jul 19 00:15 ..
|
||||
drwxr-xr-x 10 4096 Jul 19 00:02 20190719000013Z
|
||||
drwxr-xr-x 10 4096 Jul 19 00:14 20190719001241Z
|
||||
drwxr-xr-x 9 4096 Jul 22 20:30 20190722203038Z
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# `ansible-playbook` <br>`-i hosts.yml` <br>`rollback.yml`
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# rollback.yml
|
||||
---
|
||||
- hosts: all
|
||||
|
||||
roles:
|
||||
- ansistrano.rollback
|
||||
|
||||
vars:
|
||||
ansistrano_deploy_to: '{{ project_deploy_dir }}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# **Customising Ansistrano: <br>Build Hooks**
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ Shared = files directory, logs
|
||||
Before/after symlink shared = run tests
|
||||
Symlink = 'current' symlink, site is live
|
||||
Clean up = remove node_modules, database export, sqlite testing DB
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# deploy.yml
|
||||
---
|
||||
# ...
|
||||
|
||||
ansistrano_after_symlink_shared_tasks_file: '{{ playbook_dir }}/deploy/after-symlink-shared.yml'
|
||||
ansistrano_after_symlink_tasks_file: '{{ playbook_dir }}/deploy/after-symlink.yml'
|
||||
ansistrano_after_update_code_tasks_file: '{{ playbook_dir }}/deploy/after-update-code.yml'
|
||||
|
||||
release_web_path: '{{ ansistrano_release_path.stdout }}/web'
|
||||
release_drush_path: '{{ ansistrano_release_path.stdout }}/vendor/bin/drush'
|
||||
```
|
||||
|
||||
^ Each step has a 'before' and 'after' step
|
||||
Ansistrano allows us to add more things by providing a path to a playbook and adding additional steps.
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# deploy/after-update-code.yml
|
||||
---
|
||||
- name: Install Composer dependencies
|
||||
composer:
|
||||
command: install
|
||||
working_dir: '{{ ansistrano_release_path.stdout }}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# deploy/after-symlink-shared.yml
|
||||
---
|
||||
- name: Run database updates
|
||||
command: '{{ release_drush_path }} --root {{ release_web_path }} updatedb'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# deploy/after-symlink.yml
|
||||
---
|
||||
- name: Clear Drupal cache
|
||||
command: '{{ release_drush_path }} --root {{ release_web_path }} cache-rebuild'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
# **Managing data <br>across deployments**
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# deploy.yml
|
||||
|
||||
vars:
|
||||
# ...
|
||||
ansistrano_shared_paths:
|
||||
- "{{ drupal_root }}/sites/default/files"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
vagrant@dransible:/app/shared/web/sites/default/files$ ls -la
|
||||
total 28
|
||||
|
||||
drwxrwxrwx 6 4096 Jul 19 00:18 .
|
||||
drwxr-xr-x 3 4096 Jul 19 00:00 ..
|
||||
drwxrwxr-x 2 4096 Jul 22 21:24 css
|
||||
-rwxrwxrwx 1 487 Jul 19 00:02 .htaccess
|
||||
drwxrwxr-x 2 4096 Jul 19 00:19 js
|
||||
drwxrwxrwx 3 4096 Jul 19 00:18 php
|
||||
drwxrwxrwx 2 4096 Jul 19 00:03 styles
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
vagrant@dransible:/app/current/web/sites/default$ ls -la
|
||||
total 48
|
||||
dr-xr-xr-x 2 4096 Jul 19 00:14 .
|
||||
drwxr-xr-x 3 4096 Jan 22 17:30 ..
|
||||
-rw-r--r-- 1 6762 Jul 19 00:14 default.services.yml
|
||||
-rw-r--r-- 1 31342 Jul 19 00:14 default.settings.php
|
||||
lrwxrwxrwx 1 45 Jul 19 00:14 files -> ../../../../../shared/web/sites/default/files
|
||||
-rw-r--r-- 1 35 Jul 19 00:12 settings.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# **Generating settings <br>files per deployment**
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# vars/vault.yml
|
||||
---
|
||||
vault_database_name: main
|
||||
vault_database_user: user
|
||||
vault_database_password: secret
|
||||
vault_hash_salt: dfgiy$fd2!34gsf2*34g74
|
||||
```
|
||||
---
|
||||
|
||||
```yaml
|
||||
# vars/vars.yml
|
||||
---
|
||||
database_name: "{{ vault_database_name }}"
|
||||
database_password: "{{ vault_database_password }}"
|
||||
database_user: "{{ vault_database_user }}"
|
||||
hash_salt: "{{ vault_hash_salt }}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# vars/vars.yml
|
||||
---
|
||||
drupal_settings:
|
||||
- drupal_root: /app/web
|
||||
sites:
|
||||
- name: default
|
||||
settings:
|
||||
databases:
|
||||
default:
|
||||
default:
|
||||
driver: mysql
|
||||
host: localhost
|
||||
database: '{{ database_name }}'
|
||||
username: '{{ database_user }}'
|
||||
password: '{{ database_password }}'
|
||||
hash_salt: '{{ hash_salt }}'
|
||||
config_directories:
|
||||
sync: ../config/sync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```php
|
||||
// templates/settings.php.j2
|
||||
|
||||
// {{ ansible_managed }}
|
||||
|
||||
{% for key, values in item.1.settings.databases.items() %}
|
||||
{% for target, values in values.items() %}
|
||||
$databases['{{ key }}']['{{ target }}'] = array(
|
||||
'driver' => '{{ values.driver|default('mysql') }}',
|
||||
'host' => '{{ values.host|default('localhost') }}',
|
||||
'database' => '{{ values.database }}',
|
||||
'username' => '{{ values.username }}',
|
||||
'password' => '{{ values.password }}',
|
||||
);
|
||||
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{% if item.1.settings.base_url is defined %}
|
||||
$base_url = '{{ item.1.settings.base_url }}';
|
||||
{% endif %}
|
||||
|
||||
{# ... #}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# tasks/main.yml
|
||||
---
|
||||
- name: Ensure directory exists
|
||||
file:
|
||||
state: directory
|
||||
path: '{{ item.0.drupal_root }}/sites/{{ item.1.name|default("default") }}'
|
||||
with_subelements:
|
||||
- '{{ drupal_settings }}'
|
||||
- sites
|
||||
no_log: true
|
||||
|
||||
- name: Create settings files
|
||||
template:
|
||||
src: settings.php.j2
|
||||
dest: '{{ item.0.drupal_root }}/sites/{{ item.1.name|default("default") }}/{{ item.1.filename|default("settings.php") }}'
|
||||
with_subelements:
|
||||
- '{{ drupal_settings }}'
|
||||
- sites
|
||||
no_log: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# **Multiple environments**
|
||||
## Dev, test, production
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
mysql_databases:
|
||||
- name: production
|
||||
- name: staging
|
||||
|
||||
mysql_users:
|
||||
- name: production
|
||||
password: '{{ live_db_password }}'
|
||||
priv: '{{ live_db_name }}.*:ALL'
|
||||
|
||||
- name: staging
|
||||
password: '{{ staging_db_password }}'
|
||||
priv: staging.*:ALL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# inventories/live.yml
|
||||
---
|
||||
all:
|
||||
hosts:
|
||||
webservers:
|
||||
ansible_ssh_host: 192.168.33.10
|
||||
ansible_ssh_port: 22
|
||||
|
||||
project_deploy_path: /app
|
||||
git_branch: master
|
||||
|
||||
drupal_hash_salt: "{{ vault_drupal_hash_salt }}"
|
||||
drupal_install: true
|
||||
|
||||
drupal_settings:
|
||||
# ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```yaml
|
||||
# inventories/staging.yml
|
||||
---
|
||||
all:
|
||||
hosts:
|
||||
webservers:
|
||||
ansible_ssh_host: 192.168.33.10
|
||||
ansible_ssh_port: 22
|
||||
|
||||
project_deploy_path: /app-test
|
||||
git_branch: develop
|
||||
|
||||
drupal_hash_salt: "{{ vault_drupal_hash_salt }}"
|
||||
drupal_install: true
|
||||
|
||||
drupal_settings:
|
||||
# ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# `ansible-playbook deploy.yml -i inventories/staging.yml`
|
||||
|
||||
---
|
||||
|
||||
# `ansible-playbook deploy.yml -i inventories/live.yml`
|
||||
|
||||
---
|
||||
|
||||
# **Questions?**
|
BIN
src/drupal-testing-workshop/images/2c6qi8.jpg
Normal file
After Width: | Height: | Size: 67 KiB |
BIN
src/drupal-testing-workshop/images/appnovation.png
Normal file
After Width: | Height: | Size: 7.9 KiB |
BIN
src/drupal-testing-workshop/images/broadbean.png
Normal file
After Width: | Height: | Size: 93 KiB |
BIN
src/drupal-testing-workshop/images/collection-class-1.png
Normal file
After Width: | Height: | Size: 142 KiB |
BIN
src/drupal-testing-workshop/images/collection-class-2.png
Normal file
After Width: | Height: | Size: 382 KiB |
BIN
src/drupal-testing-workshop/images/d8-simpletest-1.png
Normal file
After Width: | Height: | Size: 381 KiB |
BIN
src/drupal-testing-workshop/images/d8-simpletest-2.png
Normal file
After Width: | Height: | Size: 398 KiB |
BIN
src/drupal-testing-workshop/images/d8-simpletest-3.png
Normal file
After Width: | Height: | Size: 338 KiB |
BIN
src/drupal-testing-workshop/images/d8-simpletest-4.png
Normal file
After Width: | Height: | Size: 370 KiB |
BIN
src/drupal-testing-workshop/images/d8-simpletest-5.png
Normal file
After Width: | Height: | Size: 430 KiB |
BIN
src/drupal-testing-workshop/images/d8-simpletest-6.png
Normal file
After Width: | Height: | Size: 433 KiB |
BIN
src/drupal-testing-workshop/images/d8-simpletest-7.png
Normal file
After Width: | Height: | Size: 663 KiB |
BIN
src/drupal-testing-workshop/images/dcbristol.png
Normal file
After Width: | Height: | Size: 1.3 MiB |
BIN
src/drupal-testing-workshop/images/deploy-all-the-things.jpg
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
src/drupal-testing-workshop/images/files.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
src/drupal-testing-workshop/images/homer-smart.png
Normal file
After Width: | Height: | Size: 554 KiB |
BIN
src/drupal-testing-workshop/images/kernel-tests.png
Normal file
After Width: | Height: | Size: 211 KiB |
BIN
src/drupal-testing-workshop/images/matt-stauffer-tweet.png
Normal file
After Width: | Height: | Size: 48 KiB |
BIN
src/drupal-testing-workshop/images/me.jpg
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
src/drupal-testing-workshop/images/phpstorm-integration.png
Normal file
After Width: | Height: | Size: 660 KiB |
BIN
src/drupal-testing-workshop/images/phpunit.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
src/drupal-testing-workshop/images/simpletest-1.png
Normal file
After Width: | Height: | Size: 174 KiB |
BIN
src/drupal-testing-workshop/images/simpletest-2.png
Normal file
After Width: | Height: | Size: 195 KiB |
BIN
src/drupal-testing-workshop/images/simpletest-3.png
Normal file
After Width: | Height: | Size: 197 KiB |
BIN
src/drupal-testing-workshop/images/simpletest-4.png
Normal file
After Width: | Height: | Size: 459 KiB |
BIN
src/drupal-testing-workshop/images/simpletest.png
Normal file
After Width: | Height: | Size: 6.7 KiB |
BIN
src/drupal-testing-workshop/images/tdd-blog-1.png
Normal file
After Width: | Height: | Size: 413 KiB |
BIN
src/drupal-testing-workshop/images/tdd-blog-2.png
Normal file
After Width: | Height: | Size: 523 KiB |
BIN
src/drupal-testing-workshop/images/tdd-blog-3.png
Normal file
After Width: | Height: | Size: 60 KiB |
BIN
src/drupal-testing-workshop/images/tdd-blog-4.png
Normal file
After Width: | Height: | Size: 68 KiB |
BIN
src/drupal-testing-workshop/images/tdd-blog-5.png
Normal file
After Width: | Height: | Size: 75 KiB |
BIN
src/drupal-testing-workshop/images/tdd-circle-of-life.png
Normal file
After Width: | Height: | Size: 19 KiB |
BIN
src/drupal-testing-workshop/images/tdd-loop.png
Normal file
After Width: | Height: | Size: 41 KiB |
BIN
src/drupal-testing-workshop/images/timmillwood-ono.png
Normal file
After Width: | Height: | Size: 102 KiB |
BIN
src/drupal-testing-workshop/images/title.png
Normal file
After Width: | Height: | Size: 129 KiB |
BIN
src/drupal-testing-workshop/images/toggle-optional-fields-1.png
Normal file
After Width: | Height: | Size: 232 KiB |
BIN
src/drupal-testing-workshop/images/toggle-optional-fields-2.png
Normal file
After Width: | Height: | Size: 332 KiB |
BIN
src/drupal-testing-workshop/images/toggle-optional-fields-3.png
Normal file
After Width: | Height: | Size: 417 KiB |
After Width: | Height: | Size: 28 KiB |
BIN
src/drupal-testing-workshop/images/when-you-do-things-right.jpg
Normal file
After Width: | Height: | Size: 47 KiB |
1368
src/drupal-testing-workshop/slides.md
Normal file
BIN
src/having-fun-drupal-8-drupalorg-api/images/api-page.png
Normal file
After Width: | Height: | Size: 451 KiB |
BIN
src/having-fun-drupal-8-drupalorg-api/images/api-result.png
Normal file
After Width: | Height: | Size: 416 KiB |
After Width: | Height: | Size: 330 KiB |
After Width: | Height: | Size: 232 KiB |
BIN
src/having-fun-drupal-8-drupalorg-api/images/drupalversary.png
Normal file
After Width: | Height: | Size: 265 KiB |
BIN
src/having-fun-drupal-8-drupalorg-api/images/library-github.png
Normal file
After Width: | Height: | Size: 400 KiB |
After Width: | Height: | Size: 344 KiB |
BIN
src/having-fun-drupal-8-drupalorg-api/images/library-travis.png
Normal file
After Width: | Height: | Size: 385 KiB |
After Width: | Height: | Size: 420 KiB |
BIN
src/having-fun-drupal-8-drupalorg-api/images/spatie-website.png
Normal file
After Width: | Height: | Size: 2 MiB |
62
src/having-fun-drupal-8-drupalorg-api/notes.md
Normal file
|
@ -0,0 +1,62 @@
|
|||
# Having fun with Drupal 8, PHP libraries Drupal.org API
|
||||
|
||||
- Open slides
|
||||
- Open Chrome tabs
|
||||
- Open PhpStorm for all projects
|
||||
- Open Sequel Pro for both DBs
|
||||
+ Clear cache tables
|
||||
- Start PHP server for each site
|
||||
- Start recording
|
||||
|
||||
## PHP library
|
||||
|
||||
- Show in PhpStorm
|
||||
- Show readme with examples
|
||||
+ Run test.php
|
||||
- Show query classes
|
||||
+ Not drupal coding standards (PSR-2)
|
||||
+ Explain laravel collections
|
||||
- Show entity classes
|
||||
- Show tests
|
||||
+ Show fake query classes
|
||||
- Run tests
|
||||
|
||||
## Project statistics
|
||||
|
||||
- Show /projects page
|
||||
- Show routing
|
||||
- Show ProjectController
|
||||
+ Using PHP 7 return types
|
||||
+ Explain dependency injection
|
||||
+ Explain about collection
|
||||
+ Explain render array
|
||||
+ Show how to change ordering
|
||||
- Show ProjectRetriever
|
||||
+ More dependency injection
|
||||
+ Show services file
|
||||
- Show settings form
|
||||
- Add another module (Sophie's simple integrations?)
|
||||
+ See it loading on projects page
|
||||
+ See it cached
|
||||
|
||||
## Drupalversary
|
||||
|
||||
- Show front page
|
||||
- Show block
|
||||
- Show block form
|
||||
- Show accountretriever
|
||||
+ Highlight caching
|
||||
+ Services file
|
||||
+ Show cached items in the DB
|
||||
- Show routing
|
||||
- Show UserController
|
||||
+ More dependency injection
|
||||
+ Services file
|
||||
- Show date parser
|
||||
+ Show drupalversary model
|
||||
- Show adding own username via form
|
||||
+ See it cached
|
||||
+ Show adding uid via form
|
||||
+ See it cached
|
||||
- Show Dries' because this year drupalversary has passed
|
||||
- Try with attendee ID
|
210
src/having-fun-drupal-8-drupalorg-api/slides.txt
Normal file
|
@ -0,0 +1,210 @@
|
|||
autoscale: true
|
||||
build-lists: true
|
||||
footer-style: alignment(left)
|
||||
footer: @opdavies | opdavi.es
|
||||
header-emphasis: #53B0EB
|
||||
header: alignment(left)
|
||||
text: alignment(left)
|
||||
text-emphasis: #53B0EB
|
||||
theme: poster, 8
|
||||
|
||||
[.header: alignment(center)]
|
||||
|
||||
## Having fun with
|
||||
## _Drupal 8_, _PHP libraries_
|
||||
## and the _Drupal.org API_
|
||||
|
||||
---
|
||||
|
||||
[.background-color: #FFFFFF]
|
||||
[.build-lists: false]
|
||||
[.header: #111111]
|
||||
[.text: #111111, alignment(left)]
|
||||
|
||||

|
||||
|
||||
- Full stack Web Developer & System Administrator
|
||||
- Senior Developer at Microserve
|
||||
- Part-time freelancer
|
||||
- Acquia certified Drupal 8 Grand Master
|
||||
- Drupal core contributor
|
||||
- Open source project maintainer
|
||||
- opdavies (Drupal.org, GitHub, Twitter)
|
||||
- www.oliverdavies.uk
|
||||
|
||||
^ Work at Microserve.
|
||||
Maintain Drupal modules, PHP CLI tools and libraries
|
||||
Talking about some open source project that I've written or in the process of writing.
|
||||
|
||||
---
|
||||
|
||||
[.header: alignment(center)]
|
||||
|
||||
## _Drupal 8 crash course_
|
||||
|
||||
### PHP libraries, Unit testing,
|
||||
### Composer, Routing, Services,
|
||||
### Dependency injection, caching
|
||||
|
||||
^ Please feel free to ask questions as we go along!
|
||||
|
||||
---
|
||||
|
||||
[.header: alignment(center)]
|
||||
|
||||
## _Live Demo_ alert!
|
||||
|
||||
---
|
||||
|
||||
[.hide-footer]
|
||||
|
||||

|
||||
|
||||
^ In 2018 we rebuilt the Microserve website
|
||||
|
||||
---
|
||||
|
||||
[.hide-footer]
|
||||
|
||||

|
||||
|
||||
^ Looking for a way to replicate Spatie's open source page for Drupal
|
||||
|
||||
---
|
||||
|
||||
### _Did you know that_
|
||||
## Drupal.org has an API?
|
||||
|
||||
^ We're all familiar with Drupal.org (it's where everyone registered for this event), but did you know D.o has a public API?
|
||||
|
||||
---
|
||||
|
||||
[.hide-footer]
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
[.hide-footer]
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### _A PHP library for the_
|
||||
## Drupal.org API
|
||||
|
||||
^ The first thing I built was...
|
||||
It's what the Drupal examples are built on top of.
|
||||
|
||||
---
|
||||
|
||||
- Retrieve _node_ and _user_ data
|
||||
- Filter by properties
|
||||
- Provides own entity classes
|
||||
- Methods for retrieving common properties
|
||||
- Reusable
|
||||
- Unit tested
|
||||
|
||||
^ Re-usable by Drupal 7, Drupal 8, Symfony, Laravel etc
|
||||
Testing already covered.
|
||||
Demo
|
||||
|
||||
---
|
||||
|
||||
[.hide-footer]
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
[.hide-footer]
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
[.hide-footer]
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
```bash
|
||||
$ composer require opdavies/drupalorg-api-php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### _Displaying Drupal.org_
|
||||
## project statistics
|
||||
|
||||
---
|
||||
|
||||
[.text: alignment(left)]
|
||||
|
||||
- Inspired by _spatie.be/en/opensource_
|
||||
- Displays _downloads_ and _stars_
|
||||
- Drupal 8 module
|
||||
+ Configuration form to enter project IDs
|
||||
+ Queries the API
|
||||
+ Displays project information
|
||||
+ Retrieved data cached locally
|
||||
|
||||
^ Demo
|
||||
|
||||
---
|
||||
|
||||
[.hide-footer]
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
[.hide-footer]
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### _When is your_
|
||||
## Drupalversary?
|
||||
|
||||
---
|
||||
|
||||
[.text: alignment(left)]
|
||||
|
||||
- When did you register on Drupal.org?
|
||||
- Drupal 8 module
|
||||
+ Enter a username
|
||||
+ Queries the API
|
||||
+ Displays information - next Drupalversary _date_ and _number of days_ until, _number of years_ on Drupal.org
|
||||
+ Retrieved data cached locally
|
||||
|
||||
^ Stored in cache tables, but could use a custom entity?
|
||||
Demo
|
||||
|
||||
---
|
||||
|
||||
[.hide-footer]
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
- opdavi.es/_talks_
|
||||
- opdavi.es/_do-library_
|
||||
- opdavi.es/_drupalversary_
|
||||
- opdavi.es/_do-projects_
|
||||
|
||||
---
|
||||
|
||||
[.header: alignment(center)]
|
||||
|
||||
## Questions?
|
||||
|
||||
---
|
||||
|
||||
[.header: alignment(center)]
|
||||
|
||||
# Thanks
|
BIN
src/images/me-microserve.jpg
Normal file
After Width: | Height: | Size: 2.1 MiB |
BIN
src/images/me-phpnw-inviqa.jpg
Normal file
After Width: | Height: | Size: 47 KiB |
BIN
src/images/me-phpnw-inviqa.png
Normal file
After Width: | Height: | Size: 177 KiB |
BIN
src/images/me-phpnw.png
Normal file
After Width: | Height: | Size: 177 KiB |
BIN
src/images/me-precedent.jpg
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
src/images/microserve-light.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/0.png
Normal file
After Width: | Height: | Size: 65 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/1.png
Normal file
After Width: | Height: | Size: 66 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/10.png
Normal file
After Width: | Height: | Size: 86 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/11.png
Normal file
After Width: | Height: | Size: 86 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/12.png
Normal file
After Width: | Height: | Size: 92 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/13.png
Normal file
After Width: | Height: | Size: 86 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/14.png
Normal file
After Width: | Height: | Size: 93 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/2.png
Normal file
After Width: | Height: | Size: 67 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/3.png
Normal file
After Width: | Height: | Size: 68 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/4.png
Normal file
After Width: | Height: | Size: 70 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/5.png
Normal file
After Width: | Height: | Size: 74 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/6.png
Normal file
After Width: | Height: | Size: 82 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/7.png
Normal file
After Width: | Height: | Size: 84 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/8.png
Normal file
After Width: | Height: | Size: 85 KiB |
BIN
src/taking-flight-with-tailwind-css/images/example/9.png
Normal file
After Width: | Height: | Size: 85 KiB |
After Width: | Height: | Size: 140 KiB |
After Width: | Height: | Size: 92 KiB |
After Width: | Height: | Size: 160 KiB |
10
src/taking-flight-with-tailwind-css/images/tailwind.svg
Normal file
|
@ -0,0 +1,10 @@
|
|||
<svg class="w-10 h-10 lg:w-12 lg:h-12 block" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Tailwind CSS</title>
|
||||
<path d="M13.5 11.1C15.3 3.9 19.8.3 27 .3c10.8 0 12.15 8.1 17.55 9.45 3.6.9 6.75-.45 9.45-4.05-1.8 7.2-6.3 10.8-13.5 10.8-10.8 0-12.15-8.1-17.55-9.45-3.6-.9-6.75.45-9.45 4.05zM0 27.3c1.8-7.2 6.3-10.8 13.5-10.8 10.8 0 12.15 8.1 17.55 9.45 3.6.9 6.75-.45 9.45-4.05-1.8 7.2-6.3 10.8-13.5 10.8-10.8 0-12.15-8.1-17.55-9.45-3.6-.9-6.75.45-9.45 4.05z" transform="translate(5 16)" fill="url(#logoMarkGradient)" fill-rule="evenodd"></path>
|
||||
<defs>
|
||||
<linearGradient x1="0%" y1="0%" y2="100%" id="logoMarkGradient">
|
||||
<stop stop-color="#2298BD"></stop>
|
||||
<stop offset="1" stop-color="#0ED7B5"></stop>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 918 B |
BIN
src/taking-flight-with-tailwind-css/images/techs.png
Normal file
After Width: | Height: | Size: 86 KiB |
|
@ -0,0 +1,883 @@
|
|||
autoscale: true
|
||||
theme: Plain Jane, 1
|
||||
|
||||
# **Taking Flight with <br>Tailwind CSS**
|
||||
|
||||

|
||||
|
||||
^ Tailwind CSS is a framework that I've been using for the last year and a half
|
||||
Going to be using Tailwind 1.0 which was released recently (May 13th)
|
||||
|
||||
---
|
||||
|
||||
- PHP and Front End Developer
|
||||
- System Administrator
|
||||
- Senior Engineer at Inviqa
|
||||
- Part-time freelancer
|
||||
- Open sourcer
|
||||
- @opdavies
|
||||
- oliverdavies.uk
|
||||
|
||||

|
||||
|
||||
^ Co-organiser of PHP South Wales and DrupalCamp Bristol
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
# **What is Tailwind CSS?**
|
||||
|
||||
---
|
||||
|
||||
[.footer: tailwindcss.com]
|
||||
|
||||
# A **utility-first** CSS framework for rapidly building **custom designs**.
|
||||
|
||||
^ CSS utility class generator
|
||||
PostCSS
|
||||
Make different looking sites using the same class names
|
||||
No "Tailwind looking site" like there is with Bootstrap
|
||||
|
||||
---
|
||||
|
||||
[.footer: tailwindcss.com]
|
||||
|
||||
# Tailwind CSS is a **highly customizable**, **low-level** CSS framework
|
||||
|
||||
^ No components like Bootstrap or Bulma
|
||||
Configure it per project
|
||||
Extendable if needed via additional plugins
|
||||
Avoids the need to name things prematurely
|
||||
Can extract components if needed (reusability)
|
||||
|
||||
---
|
||||
|
||||
[.footer: tailwindcss.com/docs/what-is-tailwind/#designed-to-be-customized]
|
||||
|
||||
# Tailwind is more than a CSS framework, it's an engine for <br>**creating design systems**.
|
||||
|
||||
^ Good default values provided - colours, fonts, padding, widths
|
||||
Designing with constraints. Using inline styles, every value is a magic number. With utilities, you're choosing styles from a predefined design system, which makes it much easier to build visually consistent UIs.
|
||||
|
||||
---
|
||||
|
||||
- Text/border/background colours
|
||||
- Font size/family/weight
|
||||
- Alignment
|
||||
- Padding/margin/negative margin
|
||||
- Flexbox
|
||||
- Positioning
|
||||
- Lists
|
||||
- z-index
|
||||
- Opacity
|
||||
- ...
|
||||
|
||||
^ All generated from a single, customisable configuration file.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
# **How do I use Tailwind?**
|
||||
|
||||
^ From the new tailwindcss.com website
|
||||
|
||||
---
|
||||
|
||||
# With Tailwind, you style elements <br>by **applying pre-existing classes** directly in your HTML.
|
||||
|
||||
---
|
||||
|
||||
# Using **utility classes** to build custom designs **without writing CSS**
|
||||
|
||||
---
|
||||
|
||||
## **Benefits**
|
||||
- You aren't wasting time and energy inventing class names
|
||||
- Your CSS stops growing
|
||||
- Making changes feels safer
|
||||
|
||||
^ No more adding silly class names like sidebar-inner-wrapper just to be able to style something, and no more agonizing over the perfect abstract name for something that's really just a flex container.
|
||||
|
||||
^ Using a traditional approach, your CSS files get bigger every time you add a new feature. With utilities, everything is reusable so you rarely need to write new CSS.
|
||||
|
||||
^ CSS is global and you never know what you're breaking when you make a change. Classes in your HTML are local, so you can change them without worrying about something else breaking.
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ Add padding with p-6
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ Rounded image - rounded-full
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ Centre image using mx-auto
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ Larger text - text-lg
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ Purple text - text-purple-500
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ Grey text - text-gray-600
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ Centre text - text-center
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ Responsive: enable flexbox on medium screens - md:flex
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ Remove margin around image - md:mx-0
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ Re-align text on medium screens - md:text-left
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ md:mr-6 - add margin to the side of the image on medium screens
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ Increase image size - md:h-24 md:w-24
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
^ Smaller view
|
||||
|
||||
---
|
||||
|
||||
# **How do I install Tailwind?**
|
||||
|
||||
---
|
||||
|
||||
# **1. Use the CDN**
|
||||
|
||||
---
|
||||
|
||||
[.footer: https://next.tailwindcss.com/docs/installation]
|
||||
|
||||
## **https://unpkg.com/tailwindcss/dist/tailwind.min.css**
|
||||
|
||||
---
|
||||
|
||||
[.footer: https://next.tailwindcss.com/docs/installation]
|
||||
|
||||
## **To get the most out of Tailwind, <br>you really should install it via npm.**
|
||||
|
||||
^ - You can't customize Tailwind's default theme
|
||||
- You can't use any directives like *@apply*, *@variants*, etc.
|
||||
- You can't enable features like *group-hover*
|
||||
- You can't install third-party plugins
|
||||
|
||||
---
|
||||
|
||||
## **2. Installing Tailwind via NPM**
|
||||
|
||||
---
|
||||
|
||||
## `npm install --save-dev` <br>`tailwindcss`
|
||||
|
||||
## `yarn add -D tailwindcss`
|
||||
|
||||
^ Adds it as a dependency to your package.json file
|
||||
|
||||
---
|
||||
|
||||
## **Adding Tailwind to your CSS**
|
||||
|
||||
---
|
||||
|
||||
[.code-highlight: 2-7]
|
||||
|
||||
```css
|
||||
# src/css/style.css
|
||||
|
||||
@tailwind base;
|
||||
|
||||
@tailwind components;
|
||||
|
||||
@tailwind utilities;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
[.code-highlight: 5,9,13]
|
||||
|
||||
```
|
||||
# app.css
|
||||
|
||||
@tailwind base;
|
||||
|
||||
# Custom base styles
|
||||
|
||||
@tailwind components;
|
||||
|
||||
# Custom components
|
||||
|
||||
@tailwind utilities;
|
||||
|
||||
# Custom utilities
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **Processing your CSS with Tailwind <br>with the build command**
|
||||
|
||||
^ Compile the generated CSS
|
||||
Pass through PostCSS and Tailwind
|
||||
|
||||
---
|
||||
|
||||
# `npx tailwind build` <br>`src/css/app.css` <br>`-o dist/css/app.css`
|
||||
|
||||
---
|
||||
|
||||
```css
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.text-justify {
|
||||
text-align: justify;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **Processing your CSS with Tailwind <br>with Laravel Mix**
|
||||
|
||||
---
|
||||
|
||||
# `npm install --save-dev laravel-mix`
|
||||
|
||||
---
|
||||
|
||||
```js
|
||||
const mix = require('laravel-mix')
|
||||
|
||||
mix.postCss('src/css/app.css', 'dist/css', [
|
||||
require('tailwindcss')()
|
||||
])
|
||||
```
|
||||
|
||||
^ PostCSS - useful if you're including other PostCSS plugins like PostCSS Nested
|
||||
|
||||
---
|
||||
|
||||
```js
|
||||
const mix = require('laravel-mix')
|
||||
|
||||
require('laravel-mix-tailwind')
|
||||
|
||||
mix.postCss('src/css/app.css', 'dist/css')
|
||||
.tailwind()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>My new website</title>
|
||||
<link rel="stylesheet" href="/dist/css/app.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# `npm run dev`
|
||||
|
||||
# `npm run watch`
|
||||
|
||||
# `npm run prod`
|
||||
|
||||
|
||||
---
|
||||
|
||||
# **Interaction states**
|
||||
## hover, focus, group-hover, focus-within
|
||||
|
||||
^ Start to differ from inline styles
|
||||
|
||||
---
|
||||
|
||||
# `.[state][separator][class]`
|
||||
|
||||
^ State = hover, focus, group focus, focus within
|
||||
Separator = configurable, colon by default
|
||||
Class = the same utility class that you would have used normally
|
||||
|
||||
---
|
||||
|
||||
# `.hover:text-red-500`
|
||||
|
||||
---
|
||||
|
||||
```html
|
||||
<a href="#" class="text-red-500 hover:text-red-800">
|
||||
Read more
|
||||
</a>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```css
|
||||
.text-red-500 {
|
||||
color: #f56565;
|
||||
}
|
||||
|
||||
.hover\:text-red-500:hover {
|
||||
color: #f56565;
|
||||
}
|
||||
|
||||
.focus\:text-red-500:focus {
|
||||
color: #f56565;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```js
|
||||
// defaultConfig.stub.js
|
||||
|
||||
variants: {
|
||||
alignContent: ['responsive'],
|
||||
alignItems: ['responsive'],
|
||||
alignSelf: ['responsive'],
|
||||
appearance: ['responsive'],
|
||||
backgroundAttachment: ['responsive'],
|
||||
backgroundColor: ['responsive', 'hover', 'focus'],
|
||||
backgroundPosition: ['responsive'],
|
||||
backgroundRepeat: ['responsive'],
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# **Responsive**
|
||||
|
||||
^ Mobile first
|
||||
|
||||
---
|
||||
|
||||
# [fit] `.[screen][separator][class]`
|
||||
|
||||
---
|
||||
|
||||
```js
|
||||
// defaultConfig.stub.js
|
||||
|
||||
screens: {
|
||||
sm: '640px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
xl: '1280px',
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# `md:flex`
|
||||
|
||||
---
|
||||
|
||||
```html
|
||||
<div class="block md:flex">
|
||||
<div class="w-full md:w-1/2">
|
||||
Column 1
|
||||
</div>
|
||||
|
||||
<div class="w-full md:w-1/2">
|
||||
Column 2
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```css
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:block {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:block {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# **Keeping Things Small: <br>Controlling the File Size**
|
||||
|
||||
---
|
||||
|
||||
# Disabling unused variants <br>and core plugins
|
||||
|
||||
---
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
|
||||
variants: {
|
||||
alignContent: ['responsive'],
|
||||
alignItems: ['responsive'],
|
||||
alignSelf: ['responsive'],
|
||||
appearance: ['responsive'],
|
||||
backgroundAttachment: ['responsive'],
|
||||
backgroundColor: ['responsive', 'hover', 'focus'],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```diff
|
||||
// tailwind.config.js
|
||||
|
||||
variants: {
|
||||
alignContent: ['responsive'],
|
||||
alignItems: ['responsive'],
|
||||
- alignSelf: ['responsive'],
|
||||
+ alignSelf: false,
|
||||
appearance: ['responsive'],
|
||||
backgroundAttachment: ['responsive'],
|
||||
- backgroundColor: ['responsive', 'hover', 'focus'],
|
||||
+ backgroundColor: ['responsive'],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Manually removing unused or unwanted classes
|
||||
|
||||
---
|
||||
|
||||
```js
|
||||
screens: {
|
||||
sm: '640px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
xl: '1280px',
|
||||
},
|
||||
colors: {
|
||||
transparent: 'transparent',
|
||||
|
||||
black: '#000',
|
||||
white: '#fff',
|
||||
|
||||
gray: {
|
||||
100: '#f7fafc',
|
||||
200: '#edf2f7',
|
||||
300: '#e2e8f0',
|
||||
400: '#cbd5e0',
|
||||
500: '#a0aec0',
|
||||
600: '#718096',
|
||||
700: '#4a5568',
|
||||
800: '#2d3748',
|
||||
900: '#1a202c',
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```diff
|
||||
screens: {
|
||||
sm: '640px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
- xl: '1280px',
|
||||
},
|
||||
colors: {
|
||||
transparent: 'transparent',
|
||||
|
||||
black: '#000',
|
||||
white: '#fff',
|
||||
|
||||
gray: {
|
||||
100: '#f7fafc',
|
||||
- 200: '#edf2f7',
|
||||
300: '#e2e8f0',
|
||||
- 400: '#cbd5e0',
|
||||
- 500: '#a0aec0',
|
||||
600: '#718096',
|
||||
700: '#4a5568',
|
||||
- 800: '#2d3748',
|
||||
900: '#1a202c',
|
||||
},
|
||||
```
|
||||
|
||||
^ Needs to be done manually
|
||||
|
||||
---
|
||||
|
||||
# Automatically removing <br>unused classes
|
||||
|
||||
---
|
||||
|
||||
# `npm install --save-dev laravel-mix-purgecss`
|
||||
|
||||
---
|
||||
|
||||
```js
|
||||
const mix = require('laravel-mix')
|
||||
|
||||
mix.postCss('src/css/site.css', 'dist/css')
|
||||
.purgeCss({
|
||||
folders: ['templates'],
|
||||
extensions: ['html', 'php', 'twig']
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
[.code-highlight: 1,3,6-9]
|
||||
|
||||
```js
|
||||
const mix = require('laravel-mix')
|
||||
|
||||
require('laravel-mix-purgecss')
|
||||
|
||||
mix.postCss('src/css/site.css', 'dist/css')
|
||||
.purgeCss({
|
||||
folders: ['templates'],
|
||||
extensions: ['html', 'php', 'twig']
|
||||
})
|
||||
```
|
||||
|
||||
^ Can be tricky using Drupal/WordPress as you don't know where the classes could be coming from, no generated output directory
|
||||
|
||||
---
|
||||
|
||||
# **Avoiding Repetition: <br>Extracting Components**
|
||||
|
||||
---
|
||||
|
||||
# Does something **justify** <br>becoming a component?
|
||||
|
||||
---
|
||||
|
||||
# Could the duplication <br>**be moved elsewhere**?
|
||||
|
||||
^ Twig partials
|
||||
Vue components
|
||||
WordPress template parts
|
||||
|
||||
---
|
||||
|
||||
```twig
|
||||
{# base.html.twig #}
|
||||
|
||||
{% for item in navItems %}
|
||||
<a
|
||||
class="block py-3 px-4 text-sm text-gray-800"
|
||||
href="{{ item.url }}"
|
||||
>
|
||||
{{ item.title }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
^ Using a loop
|
||||
|
||||
---
|
||||
|
||||
```twig
|
||||
{# classes.html.twig #}
|
||||
|
||||
<h2>Adults</h2>
|
||||
|
||||
{% include 'class-list' with {
|
||||
classes: page.classes,
|
||||
type: 'adults',
|
||||
} %}
|
||||
|
||||
<h2>Kids</h2>
|
||||
|
||||
{% include 'class-list' with {
|
||||
classes: page.classes,
|
||||
type: 'kids',
|
||||
} %}
|
||||
```
|
||||
|
||||
^ Move the duplicate markup into a partial, so there's only one version
|
||||
Pass data in.
|
||||
|
||||
---
|
||||
|
||||
```css
|
||||
a.btn {
|
||||
@apply text-sm no-underline font-bold;
|
||||
@apply rounded-full inline-block px-5 py-2;
|
||||
@apply text-white bg-blue-600;
|
||||
}
|
||||
|
||||
a.btn:hover {
|
||||
@apply bg-blue-700;
|
||||
}
|
||||
```
|
||||
|
||||
^ Use utilities as mixins
|
||||
Copy classes from markup
|
||||
Still re-using the same design system and constraints as before
|
||||
|
||||
---
|
||||
|
||||
```css
|
||||
a.btn {
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
border-radius: 9999px;
|
||||
display: inline-block;
|
||||
padding-left: 1.25rem;
|
||||
padding-right: 1.25rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: #fff;
|
||||
background-color: #3182ce;
|
||||
}
|
||||
|
||||
a.btn:hover {
|
||||
background-color: #2b6cb0;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# **Customising Tailwind**
|
||||
|
||||
---
|
||||
|
||||
# `npx tailwind init`
|
||||
|
||||
---
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: [],
|
||||
variants: {}
|
||||
}
|
||||
```
|
||||
---
|
||||
|
||||
[.code-highlight: 5-7]
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
|
||||
module.exports = {
|
||||
theme: {
|
||||
colors: {
|
||||
inherit: 'inherit'
|
||||
},
|
||||
extend: {}
|
||||
},
|
||||
plugins: [],
|
||||
variants: {}
|
||||
}
|
||||
```
|
||||
|
||||
^ Overrides all colours.
|
||||
|
||||
---
|
||||
|
||||
[.code-highlight: 5-9]
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
inherit: 'inherit'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [],
|
||||
variants: {}
|
||||
}
|
||||
```
|
||||
|
||||
^ Extends Tailwind's default colours
|
||||
|
||||
---
|
||||
|
||||
[.code-highlight: 1,4-5]
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
|
||||
module.exports = {
|
||||
prefix: '',
|
||||
important: false,
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: [],
|
||||
variants: {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# `npx tailwind init --full`
|
||||
|
||||
---
|
||||
|
||||
# **Extending Tailwind CSS <br>with Plugins**
|
||||
|
||||
---
|
||||
|
||||
# `npm install --save-dev tailwindcss-list-reset`
|
||||
|
||||
---
|
||||
|
||||
[.code-highlight: 7-9]
|
||||
|
||||
```js
|
||||
// tailwind.config.js
|
||||
|
||||
module.exports = {
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: [
|
||||
require('tailwindcss-list-reset')()
|
||||
],
|
||||
variants: {}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```css
|
||||
.list-reset {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
```js
|
||||
// index.js
|
||||
|
||||
module.exports = (variants) => ({ addUtilities }) => {
|
||||
addUtilities({
|
||||
'.list-reset': {
|
||||
listStyle: 'none',
|
||||
padding: 0
|
||||
}
|
||||
}, variants)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# **Demo**
|
||||
|
||||
---
|
||||
|
||||
## **Resources**
|
||||
|
||||
- tailwindcss.com
|
||||
- tailwindcomponents.com
|
||||
- builtwithtailwind.com
|
||||
- github.com/aniftyco/awesome-tailwind
|
||||
- youtube.com/adamwathan
|
||||
- opdavi.es/tailwind-repos
|
||||
- opdavi.es/tags/tailwind-css
|
||||
|
||||
---
|
||||
|
||||
# **Questions?**
|
||||
|
||||
---
|
||||
|
||||
# **Thanks!**
|
||||
# opdavi.es/talks/tailwind
|
||||
## _@opdavies_ <br>_oliverdavies.uk_
|
||||
|
||||
^ Find this talk at opdavi.es/talks/tailwind
|
||||
Follow me on Twitter
|
||||
oliverdavies.uk where I blog about PHP, Drupal, Symfony, automated testing, Tailwind etc.
|
||||
Subscribe to the RSS feed
|