Get the best out of DDEV

I’m a Windows guy. I’ve always was, I’ll (probably?) always be. But I’m also a developer that writes quite a lot of PHP, so sometimes things aren’t working as smooth as I would like.

Until recently, my dev environment was a windows nginx.exe with a bunch of php.exe wired up and everything used to work just great. Except when it didn’t: one stupidly huge project was stupidly slow. Like >20s page load slow with xdebug disabled! So I started looking into how I could improve the performance on my machine.

My first attempt: run everything in WSL. But installing the whole stack in WSL wasn’t quite worked as I expected. There was always something off: the debugger wasn’t working. Different projects needs different PHP versions. You know.. Stuff.

My second attempt: create a docker-compose thingy and make things play nice. Although I’ve managed to create a reasonable functional setup, things were still not as smooth as I would like: the debugger was still a hit-or-miss functionality. NodeJS acted out. I wasn’t able to run multiple projects at once. But at least I got multiple PHP versions!

I had other several attempts on using various tools that create a dev environment and at some point I found wp-env, which kind of worked but not really. And then, in one of wp-env github issues, I found a comment: „I use DDEV”.

Hmm, what the hell is this DDEV?!

In short, it’s a tool that helps you to create a local dev environment in about 20s. And it’s packed with goodies. PACKED.

I won’t go on with how you can install – the documentation is amazing; so are the people in the Discord server! – it but I’ll give you some stuff that I’m doing in order to get the best out of it.

Aliases & Functions

One very annoying thing is that all commands needs to be prefixed with ddev. You want to run npm? You have to type ddev npm. Want to run composer? You guess it, you’ll have to type ddev composer. And so on. This soon become a very annoying thingy, so here is how you can fix that.

You must dump all the functions in your ~/.bashrc file. After this, don’t forget to either close & open your terminal or reload your configs

$ . ~/.bashrc 
alias reddev="ddev restart"
alias ddlogs="ddev logs -f"

By default, ddev ssh will SSH into root path. This function will put you on the current path.

ssd(){ 
  currentRoot=$(ddev describe -j | jq -r '.raw.approot')
  currentDir=$(pwd)
  rel=$(echo $currentDir | sed -r "s|^($currentRoot)|/var/www/html|g" ) 
  ddev ssh -d "$rel"
}

Alternatively, you could create a custom host ddev command, similar to context-composer below

DDEV doesn’t have a quick way to toggle xdebug, you’ll have to either type ddev xdebug to turn it on or ddev xdebug off to turn it… well, off. Here is a nice function that will toggle it instead:

xdebug(){
  xdebugStatus=$(ddev xdebug status)                                                                                                                                                                                                                                                                                                                                 
  if [[ $xdebugStatus =~ 'disabled' ]];                                                                                                                                                                                                                                                                                                                              
    then                                                                                                                                                                                                                                                                                                                                                               
      ddev xdebug on
    else                                                                                                                                                                                                                                                                                                                                                                
      ddev xdebug off
  fi                                                                                                                                                                                                                                                                                                                                                                 } 

Same goes for other commands:

wp(){ ddev wp "$@"; }
npm(){ ddev npm "$@"; }
composer(){ ddev context-composer "$@"; }

⚠️ Obviously, these commands will overwrite your machine instances of wp, npm or composer, so it’s totally up to you if/how you’re going to use them.

Oh, but what’s up with that context-composer thingy? That’s not built in into DDEV, isn’t it? Well, allow me to introduce….

Custom DDEV commands

There are a couple of options on where to store commands:

  • In the project folder (so /your/project/path/.ddev/): do this when the commands you’re writing are tied to that project OR you want to share them with your team members
  • In the global DDEV config (~/.ddev): to this when you want to have custom commands on your machine only.

For brevity, I’ll just use .ddev as path, you decide where to actually put them.

Anyway, commands are defined inside .ddev/commands and they are organized by where they are running. Kind of obvious, right?

Host Commands

These are running on your … well, host. So if your machine is Linux, these commands will run on your Linux. If your machine is Windows/WSL, then the commands will run on Windows/WSL. Easy, right?

Tmux

On all projects I kind of follow a similar pattern: open a tmux session, create a few panes, open logs in one, maybe run a npm script in another one and so on. Why not create a command for that?

Create a file: .ddev/commands/hosts/tmux and add the following:

#!/bin/bash

## Description: Start tmux session with ddev logs
## Usage: tmux [version]
## Example: "ddev tmux"

tmux new -A -s "$( ddev describe -j | jq -r '.raw.name' | sed -e 's|[^a-z0-9]|___|ig' )" ;
  split-window -h ; 
  split-window -v ; 
  send-keys 'ddev logs -f' C-m ; 

Quickly switch PHP version

On a recent project I have to migrate from an older PHP version to a newer one. I do this on a separate Git branch, therefore there are days when I have to switch between versions several times. So I need a way of set the php version!

⚠️Note that none of the following will check the availability of the selected version, so you can end up with invalid versions set! If that’s the case, edit the config file and set it manually!

Create a file: .ddev/commands/hosts/php-version

#!/bin/bash

## Description: Display or set the PHP version
## Usage: php-version [version]
## Example: "ddev php-version"

currentVersion=$(ddev describe -j | jq -r '.raw.php_version')

if [ -z "$1" ]; then
  echo "Current PHP version is $currentVersion"
else
  if [ "$currentVersion" = "$1" ]; then
    echo "PHP version is already set to $1"
  else
    ddev config --php-version "$1"
    ddev restart
    echo "PHP version set to $1"
  fi
fi

Alternatively, if you want to set the PHP version only on your machine, while you’re keeping other team members out of this, you can make use of local ddev config files:

#!/bin/bash

## Description: Display or set the PHP version locally
## Usage: php-version-local [version]
## Example: "ddev php-version-local"

configFilePath=".ddev/config.0000.local.php-version.yaml"
currentVersion=$(ddev describe -j | jq -r '.raw.php_version')

if [ ! -f $configFilePath ]; then
  touch $configFilePath
fi

if [ "$currentVersion" = "$1" ]; then 
  echo "PHP version is already set to $1"
  exit
fi

if grep -q "php_version"  $configFilePath; then
  sed -i "s/php_version:.*/php_version: $1/"  $configFilePath
else
  echo "php_version: $1" >> $configFilePath
fi

ddev restart

echo "PHP version set to $1"
Bonus: Git hook to switch PHP version

If you’d like to automagically switch PHP version on a certain branch, you can create a git post-checkout hook that will do that for you:

#!/bin/bash

php81_branches=("branch1", "branch2")
currentBranchName=$(git rev-parse --abbrev-ref HEAD)

found=false
for item in "${php81_branches[@]}"
do
    if [ "$item" = "$currentBranchName" ]; then
        found=true
        break
    fi
done

if [ "$found" = true ]; then
  echo "Setting PHP version to 8.1"
  ddev php-version-local 8.1
else
  echo "Setting PHP version to 8.0"
  ddev php-version-local 8.0
fi

Container Commands

These commands will run on the container. By default, you’ll have a couple of containers: db and web. The first is for… well, database. The second one is for everything else. It contains PHP, nginx, Node, composer, wp-cli and so on.

Context-composer

By default, ddev composer will run inside the defined composer_root in your .ddev/config.yaml (which is the project root). Most of the time this will work just fine, but there are those edge cases where you’d want to have the command running in your current directory.

Create a file: .ddev/commands/web/custom-composer:

#!/bin/bash

## Description: Run context-composer inside the web container in the root of the project (Use --working-dir= for another directory)
## Usage: context-composer [flags] [args]
## Example: "ddev context-composer install" or "ddev context-composer require foo/bar" or "ddev context-composer --working-dir=my/custom/path require foo/bar"
## ExecRaw: true
## HostWorkingDir: true

composer "$@"

That’s basically it, the whole magic is on this line: HostWorkingDir: true.

Redirect assets to the live server

On large sites, whenever you clone a live site locally, you (probably?) don’t want to clone the assets too. You can configure nginx to proxy_pass those to the live site. Unfortunately, this means that you must eliminate ddev managed nginx file:

  1. Edit .ddev/nginx_full/nginx-site.conf and delete the #ddev-generated line
  2. Scroll down to the bottom of the file & remove these two blocks:
location ~* .(png|jpg|jpeg|gif|ico)$ {
    expires max;
    log_not_found off;
}
location ~* .(js|css)$ {
    expires -1;
    log_not_found off;
}

Not sure why this conflicts with the following block, but it does 🤷 Replace it with:

location @proxy {
    # proxy_ssl on;
    # subs_filter_types *;
    proxy_ssl_server_name on;
    proxy_http_version 1.1;
    proxy_set_header Accept-Encoding "";
    proxy_set_header Cache-Control no-cache;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header X-Real-IP $remote_addr;
    add_header X-query-string $is_args$query_string;
    resolver 0.0.0.0;
    resolver_timeout 5s;
    proxy_pass https://your-live-site-url.com;
}

location /wp-content/uploads/ {
    try_files $uri $uri/ @proxy;
}

location /wp-content/blogs.dir/ {
    try_files $uri $uri/ @proxy;
}

⚠️Make sure you restart ddev after doing this!

Note that this snippet is for WordPress; for other CMS-es you’d want to replace /wp-content/uploads/ part with the CMS path!

Caveats

If you’re using PHPStorm, you should also use the DDEV plugin, works pretty much OK. BUT there are some issues.

I like to have my projects organized by their actual URL. Wich usually includes a dot. This doesn’t matter that much in any other area except on remote PHP. It’s a docker thing, so you could ignore the whys, but here is how you can deal with it:

After you install the plugin, it will auto-set-up the PHP CLI interpreter:

Which is super cool, except it is wrong on multiple levels:

  1. The environment variables should not have the dot
  2. The PHP Executable should not have the version.

So you must duplicate the DDEV entry, and have it like so:

While you’re there, make sure you include system environment variables too, otherwise you’ll get some errors and your PHP interpreter will not work.

(I’m not saying that I’ve spent half a day to figure this out, but…)

What is not working – like… at all – is remote Node interpreter. This is a PHPStorm thingy, as can’t be configured like PHP is, to reuse a docker container, so I’ll have to live with it for now.

Conclusions

I’ve been using DDEV for a couple of months now, maybe a little longer. I’m super excited about how everything is working. Sure, there is a learning curve, but is super lean and the community si amazingly helpful.

Oh, about the >20s page loads I mentioned in the begining? Now it’s less than 5 seconds with xdebug enabled.

Create Elementor migration/upgrade scripts

Every now and then, you may need to migrate something on existing Elementor widgets. One solution would be to go through every single template and change it by hand, but we’re not savages.

Note that migration in this context means that we have the widget in place but we update some of its values.

So we use code! Since I didn’t found any resource on how this could be accomplished, here is a short how to.

You can use this with either Elementor Pro or the free version (although the Pro version have some goodies built in).

In your plugin file, you add this:

// if you're using the free version:
add_action('elementor/init', 'MyUpgradeManager::instance', 99);

// if you're using the Pro version:
add_action('elementor_pro/init', 'MyUpgradeManager::instance', 99);

Next up, you need to create two classes: MyUpgradeManager and MyUpgrades. It’s recommended that you set up an autoloader for your classes, but that’s a story for another time (or you can head to Composer documentation and do your thing).

First class is the MyUpgradeManager, which only register the actions. Nothing fancy here:

<?php

!defined('ABSPATH') && die();

use Elementor\Core\Upgrade\Manager as ElementorUpgradeManager;

class MyUpgradeManager extends ElementorUpgradeManager
{
    public function get_action()
    {
        return 'elementor-my-upgrade-manager';
    }

    public function get_plugin_name()
    {
        return 'my-elementor-widgets';
    }

    public function get_plugin_label()
    {
        return esc_html__('My Elementor Widgets');
    }

    public function get_updater_label()
    {
        return esc_html__('My Elementor Widgets Data Updater');
    }

    public function get_new_version()
    {
        return '1.0.0'; 
    }

    public function get_version_option_name()
    {
        return 'elementor_my_widgets_version';
    }

    public function get_upgrades_class()
    {
        // if you're using namespaces, you need to add the full path: my\namespace\MyUpgrades
        return 'MyUpgrades'; 
    }
}

Next up, the class that actually do the changes:

<?php

use Elementor\Core\Upgrade\Upgrade_Utils;

!defined('ABSPATH') && die();

class MyUpgrades
{
    public static function do_my_changes($element, $args)
    {
        if (empty($element['widgetType']) || $args['widget_id'] !== $element['widgetType']) {
            return $element;
        }

        $element['settings']['components_to_show'] ??= [];
        $element['settings']['layout_type'] ??= $element['settings']['type'] ?? 'three_wide';

        return $element;
    }

    /**
     * Remember `get_new_version()` method from the previous class?
     * We convert that version from dots to underscore, we prefix it with `_v_` and we add the changes.
     * Note that `_v_0_0_1_changes_will_not_be_applied()` will NOT execute, as the version 0.0.1 is 
     * lower than Manager version (1.0.0)
     */
    public static function _v_0_0_1_changes_will_not_be_applied($updater)
    {
        // do nothing
    }

    public static function _v_1_0_0_my_changes($updater)
    {
        $changes = [
            [
                'callback' => ['ElementorPro\Core\Upgrade\Upgrades', '_rename_widget_settings'],
                'control_ids' => [
                    'type' => 'layout_type',
                ],
            ],

            [
                'callback' => ['MyUpgrades', 'do_my_changes'],
                'control_ids' => [],
            ],
        ];

        return Upgrade_Utils::_update_widget_settings('my-widget-name', $updater, $changes);
    }

    // this will run on each version
    public static function _on_each_version( $updater ) {}
}

What does this class do?

Inside _v_1_0_0_my_changes method, it takes a custom widget, called my-widget-name and do a couple of changes, as you can see in the $changes variable:

  1. Firstly, it will rename an existing control called type to layout_type, as it’s more suggestive on what that will do;
  2. Secondly, it will do some custom changes, as you can see inside do_my_changes method (in this case, it will set some default values)

You need to make sure that the first param of Upgrade_Utils::_update_widget_settings('my-widget-name', $updater, $changes); will match your widget name.

You can add as many methods as you want and you can get inspired by the Elementor upgrades class on what can be done.

WP CLI

In order to make it work on WP-CLI, you need to add an extra class:

class UpdateCliCommand extends UpdateBase {
    protected function get_update_db_manager_class() {
        return 'MyUpgradeManager'; // you use the same class name as above **including** namespace, if that's the case
    }
}

You also need to add the following after the initialization:

add_action('elementor/init', 'MyUpgradeManager::instance', 99);
+ if (class_exists('\WP_CLI')) {
+     \WP_CLI::add_command('my-plugin elementor update', 'UpdateCliCommand');
+ }

After you do this, you can run wp my-plugin elementor update db to… well, update the db.

N-are cum să fie asta, nu?

Acum aproape trei săptămâni fac un deploy în organizația cu care lucrez. După câteva ore, lucrurile încep să o ia la vale: procesoarele serverelor erau la 100%, memoria și baza de date abia erau atinse.

Interludiu

Înainte să intru în detalii, să spun întâi despre cum funcționează bestia.

Mediul în care rulează toată povestea este AWS, cu resurse decente: în zilele normale sunt 2-6 servere t3.xlarge (4CPU, 16Gb RAM) scalate automat, în funcție de trafic.

Toată infrastructura este administrată prin Terraform, iar pentru orice schimbare se face commit. Orice deployment este făcut prin pipeline și înseamnă un server nou pe care se reinstalează tot.

Pe lângă asta, avem două servere extra, unul pentru dev, altul pentru staging. Din motive de timp, serverele astea două extra sunt reciclate (lucru important, dar aflat abia aseară) și trec prin procesul de reinstalare doar dacă trec de un anumit threshold la resursele folosite.

De când a început nebunia, s-a făcut întâi upgrade la 10 ✕ t3.2xlarge (8CPU, 32Gb RAM), apoi la altele și mai mari, cu 32CPU. Practic, aveam 320 CPU iar site-ul murea la 100 vizitatori. 🤦‍♂️

Pentru că planetele s-au aliniat foarte bine, serverul de dev era enervant de rapid, restul erau lente. Cât de lente? +30s pentru o pagină goală. Pe dev, aceeași pagină se încărca în 3-400ms. Și ca să fie totul și mai frustrant, resursele pe serverul ăsta erau cele mai mici, gen 1CPU, 2Gb RAM.

Serverul local? All good.

Semne bune

Cum set-up-ul este minunat – WordPress multisite, WPML, Elementor, plus alte 30 plugins – primul gând a fost: este buba la un plugin. Fac rapid restore și se rezolvă. Țeapă, am făcut restore la câteva versiuni din trecut. Nimic. Fac restore la baza de date. Nimic.

După ce am luat lucrurile metodic (dezactivat plugin-uri, activat unul câte unul, install fresh etc), am ajuns la concluzia că singurul lucru care ar putea fi de vină este AWS. Sysadmin-ul zice că n-are cum, eu îi zic: hai să mai facem un server, să vedem. Nu mă, n-are cum. Humor me.

Server nou AWS. La fel de lent.

După ce am primit voie de la legal (să mut baza de date), am făcut un droplet pe Digital Ocean. Rapid. Deci problema trebuie să fie în AWS! Pe de altă parte, erau atât de multe diferențe între DO și AWS încât nu eram atât de sigur.😅

Disperare

Am încercat tot ce mi-a trecut prin cap. Am întrebat, am explicat. Profiler. Debugger. Tracing. Toate arătau că problema ar fi de la Elementor. Dar de ce nu pot replica în toate mediile?

Nimeni nu avea o explicație logică. Am fost nevoit să trec la lucruri cu adevărat serioase.

Degeaba.

Casual Talk

La un moment dat, întreb pe Slack-ul Toptal. Îmi răspunde un tip, că și el a observat probleme de performanță și îmi dă un screenshot din New Relic cu timpii de încărcare.

Încep să-l descos pe individ, să văd ce set-up are. Nu avea nimic în comun cu set-up-ul meu, cu excepția a două lucruri:

  • Elementor
  • New Relic

Nimic altceva. Nici servere, nici nu era multi-site, nici nimic.

Și apoi mă lovește: mai este o diferență între serverul de pe DO și cele din AWS: cel de pe DO nu are New Relic. Și nici cel local.

Hmmm, n-are cum să fie asta, nu?

N-are cum să fie New Relic, nu?

Și fac un test în staging: scot extensia New Relic din php.ini. Lucrurile revin instant la normal. Facem rapid un test, facem deploy șiiii….

Apoi încep să apară mai multe semne. Pe Github. Pe forum.

Explicația

Am zis mai sus treaba asta:

Pe lângă asta, avem două servere extra, unul pentru dev, altul pentru staging. Din motive de timp, serverele astea două extra sunt reciclate (lucru important, dar aflat abia aseară) și trec prin procesul de reinstalare doar dacă trec de un anumit threshold la resursele folosite.

Ei bine…

Pentru că serverele nu treceau prin procesul de reinstalare, agentul New Relic nu era modificat.

Concluzii

Am învățat/aflat/mi-am reamintit câteva lucruri în toată povestea asta:

  • nu sunt eu de vină pentru toate relele care se întâmplă 😅😅
  • că nu există „nu se poate să fie de aici”. Când ceva nu are explicație, ia în considerare orice posibilitate;
  • nu trebuie să ai încredere în sysadmin 😂 Nu pentru că ar fi rău intenționat, ci pentru că este posibil să-i scape anumite aspecte;
  • cu răbdarea treci marea.

Conflicte în composer autoload

TL;DR: folosește __DIR__ când incluzi un fișier.

Recent am avut o situație tare interesantă în WordPress: folosesc composer autoload în mai multe locuri (câteva plugin-uri, temă) și la un proiect am început să primesc erori. Autoloader-ul nu mai găsea o clasă în temă.

Rulez composer dump în temă, nu mai găsește altă clasă în pluginul 1. Rulez composer dump în plugin, nu mai găsește o clasă în pluginul 2. Rulez și acolo composer dump, nu mai găsește clasa în temă. Și tot așa.

Niște chestii interesante în treaba asta:

  1. problema apărea doar dacă rulam în WP-CLI
  2. problema NU exista în producție.
  3. problema nu există la alte proiecte.

Până când am observat o chestie: la proiectele la care nu aveam problema asta, modul în care includeam autoload.php era ușor diferit:

require_once __DIR__ . '/vendor/autoload.php';

Față de cum aveam în proiectul curent:

require_once 'vendor/autoload.php';

O explicație a problemei găsești aici.

Tips & Tricks PowerShell

Mi se pare că PowerShell are parte de mult mai puțină atenție decât merită. Pentru ce fac eu, mi se pare mult mai intuitiv/expresiv/user-friendly/readable decât Bash. Fac comparația asta pentru că (inițial) Microsoft a vrut ceva în genul Bash pe Windows. Și au mers foarte mult pe inspirație: pipes, alias-uri pentru comenzi (astfel încât, până la un punct, poți folosi comenzi Linux care funcționează aproximativ la fel).

Și în ultima vreme am făcut mici utilitare care mă ajută cu una-alta și de care am tot scris pe forum. Am zis să le centralizez aici, poate ajută și pe alții.

Pasul 0: instalarea

Dacă ești pe Windows, nu trebuie să faci nimic, PS este inclus încă de la … Win 7 (sau vista?). Dacă eșți pe altceva, poți instala fără prea mari bătăi de cap.

Pasul 1: un fel de .bashrc

Evident, nu avem așa ceva, avem $profile. Ca să-l edităm: notepad $profile. (sau subl $profile sau ce editor îți place). Aici este fișierul cu chestii, unde punem cod ce va fi accesibil din consolă.

Spre deosebire de Linux, unde avem un fișier ~/.bashrc, în PowerShell avem ~/Documents/PowerShell/Microsoft.PowerShell_profile.ps1. (calea asta este ținută în acel $profile de care am zis mai sus).

⚠️Orice modificare a acestui fișier necestă reîncărcarea acestuia: ori cu un reset al consolei ori cu execuția . $profile

Pasul 2: utilitare făcute de alții

Atât pe Linux cât și pe Mac, există … utilitare (nici nu știu cum să le spun, teme? modificări?) care adaugă tot felul de informații în shell: calea curentă, branch-ul git, ora etc.

Cele mai cunoscute (eu le folosesc doar pe primele două):

Pasul 3: utilitare proprii

Înainte de toate, punem două comenzi la începutul lui $profile:

  1. Set-PSReadlineKeyHandler -Key Tab -Function MenuComplete pentru a avea autocomplete mai deștept la TAB (util în anumite situații)
  2. Start-SshAgent -Quiet dacă folosești ssh

Organizarea fișierelor

La fel cum în .bashrc poți include fișiere externe (source my-file), la fel poți face și în PowerShell folosind Import-Module -Name my-file.ps1. La fel de frumos poți să trântești totul în $profile – nu e nici o supărare – dar spargerea în fișiere permite să dezactivezi rapid module/funcții sau distribuirea acestora.

Funcțiile de mai jos pot fi puse și în $profile și în module separate.

Autocomplete pentru SSH

Codul este luat de aici și îți pune la dispoziție autocomplete pentru host-urile definite în ssh config. Foarte util dacă te ai multe host-uri configurate. Tot în gist-ul ăsta vei găsi și utilitar care-ți oferă autocomplete în baza known_hosts.

function Get-SSHHost($sshConfigPath) {
  Get-Content -Path $sshConfigPath `
  | Select-String -Pattern '^Host ' `
  | ForEach-Object { $_ -replace 'Host ', '' } `
  | ForEach-Object { $_ -split ' ' } `
  | Sort-Object -Unique `
  | Select-String -Pattern '^.*[*!?].*$' -NotMatch
}

Register-ArgumentCompleter -CommandName 'ssh', 'scp', 'sftp' -Native -ScriptBlock {
  param($wordToComplete, $commandAst, $cursorPosition)

  $sshPath = "$env:USERPROFILE\.ssh"

  $hosts = Get-Content -Path "$sshPath\config" `
  | Select-String -Pattern '^Include ' `
  | ForEach-Object { $_ -replace 'Include ', '' }  `
  | ForEach-Object { Get-SSHHost "$sshPath/$_" }

  $hosts += Get-SSHHost "$sshPath\config"
  $hosts = $hosts | Sort-Object -Unique

  $hosts | Where-Object { $_ -like "$wordToComplete*" } `
  | ForEach-Object { $_ }
}

SSH .pub keys

Pentru că am multe chei ssh, se întâmplă uneori să fie nevoie să copiez rapid cheia publică. sshkeys urmat de tab îți va arăta cheile disponibile.

function sshkeys() {
    param($sshKeyName = 'id_rsa')

    if ( [string]::IsNullOrEmpty($sshKeyName) ) { 
      echo 'Empty Key'
      return
    }

    $sshFile = "$env:USERPROFILE\.ssh\$sshKeyName"

    if (-not(Test-Path -Path $sshFile -PathType Leaf)) {
      echo 'Invalid file'
      return
    }

    $keyValue = ssh-keygen -y -f $sshFile

    Set-Clipboard -Value "$keyValue"

    echo "Show public key for: $sshKeyName"
    echo "--------------------------------------------------"
    echo $keyValue
    echo "--------------------------------------------------"
}


Register-ArgumentCompleter  -CommandName sshkeys -Native  -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

    $excluded = 'config', 'known_hosts', 'altă-cheie-privată'

    $sshPath = "$env:USERPROFILE\.ssh"

    $keys = Get-ChildItem -Path $sshPath -File -Name
    $keys = $keys | Sort-Object -Unique

    $keys | Where-Object { 
      -not($excluded -contains $_)
    } |  Where-Object {
      $_ -notlike '*.pub'
    } |  ForEach-Object {
      $_
    }   
}

WP CLI autocomplete

Register-ArgumentCompleter  -CommandName wp, wpms -Native  -ScriptBlock {
    param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters)

    Invoke-Expression "wp cli completions --line='$parameterName' --point=100" | ForEach-Object {
       $_
    }
}

Sudo

În lumea Windows există conceptul de a rula ceva elevat, dar nu există și un mod ușor de a face asta. Și pentru că Windows nu permite elevarea unui proces existent, este nevoie să se deschidă o nouă instanță:

function sudo() {
  $curDir = (Get-Location).path
  powershell -command "Start-Process cmd -ArgumentList '/c cd /d $curDir && $args & pause' -Verb runas"
}

În rest se execută la fel ca pe Linux: sudo notepad $profile.

Cum migrezi un WordPress multisite?

Una din problemele cu care mă confrunt frecvent este aceea de a migra o instanță de WordPress de pe un domeniu pe altul (e.g. din live în development). La site-uri simple este o operațiune rapidă, wp search-replace url.vechi url.nou, dar lucrurile se complică atunci când este un multisite și/sau are o bază de date imensă.

WP are o mulțime de string-uri serializate în baza de date, deci nu e o soluție atât de simplă să faci un search/replace în dump-ul sql. Prin urmare, vom folosi wp-cli. Pentru că sunt pe Windows, vom folosi PowerShell.

Cum ne interesează ca, în primul rând, să avem o copie funcțională pe local, nu vrem neapărat să înlocuim toate string-urile. Prin urmare, putem sări anumite tabele mai puțin importante. Treaba asta nu reprezintă o problemă foarte mare la o bază de date mică, dar la o DB măricică operațiile de search-replace pot dura ore. (am un multi-site cu aproape 20 de sub-site-uri, DB bate spre 20Gb, am încercat să înlocuiesc toate string-urile și după 30h încă nu terminase…)

Salvezi codul de mai jos într-un fișier, să-i zicem migrate.ps1, și ajustezi primele linii:

  • $wpPath este directorul în care este instanța de WP. Dacă este în directorul din care rulezi scriptul de mai jos, un . este suficient.
  • $network_sites este un array de forma "site vechi" => "site nou". Este important să avem // la început pentru a evita înlocuirile în subdomenii, de exemplu. Dacă site-urile au www în față, adaugi și aia.
  • $primarySiteURL este site-ul principal
  • $tables_to_skip sunt tabelele în care nu vrei să se facă înlocuirile. De cele mai multe ori este safe să sari peste astea (și peste altele, dar astea sunt cele mai mari).
$wpPath = '.'

$network_sites = @{
    "//live-site.url" = "//local-site.url";
    "//subdomain.live-site.url" = "//subdomain.local-site.url";
}

$primarySiteURL = $network_sites["live-site.url"]

$tables_to_skip = "$($dbPrefix)posts,$($dbPrefix)postmeta,'$($dbPrefix)*_posts','$($dbPrefix)*_postmeta','$($dbPrefix)nf3_*','$($dbPrefix)*_nf3_*'"

Să reflecte necesitățile (e.g. poate ai totuși nevoie să înlocuiești datele în postmeta`

Adițional, ajustezi comenzile specifice plugin-urilor tale: elementor, wpml, ce mai ai tu. La sfârșit rulezi scriptul din terminal: ./migrate.ps1.

$wpPath = '.'

$network_sites = @{
    "//live-site.url" = "//local-site.url";
    "//subdomain.live-site.url" = "//subdomain.local-site.url";
}

$primarySiteURL = $network_sites["live-site.url"]

$dbPrefix=$(wp config get table_prefix --path=$wpPath)
$tables_to_skip = "$($dbPrefix)posts,$($dbPrefix)postmeta,'$($dbPrefix)*_posts','$($dbPrefix)*_postmeta','$($dbPrefix)nf3_*','$($dbPrefix)*_nf3_*'"

$network_sites.GetEnumerator() | ForEach-Object {
    $old_site_url = $_.Key
    $new_site_url = $_.Value

    Invoke-Expression  "wp db query ""UPDATE $($dbPrefix)blogs SET domain = '$new_site_url' WHERE domain = '$old_site_url';""  --path=""$wpPath"""

    $old_site_url = "https://$old_site_url"
    $new_site_url = "https://$new_site_url"

    $wp_cmd = "wp search-replace $old_site_url $new_site_url --path=""$wpPath"" --skip-tables=$tables_to_skip"

    echo "Replacing strings in $old_site_url site"
    Invoke-Expression "$wp_cmd --url=$new_site_url"

    echo "Replacing strings across network"
    Invoke-Expression "$wp_cmd --network"

#   echo "Replacing Elementor URLs $old_site_url -> $new_site_url"
#    wp elementor replace_urls $old_site_url $new_site_url --path="$wpPath"

#    echo "Clear WPML cache"
#    wp wpml clear-cache --url=$new_site_url --path="$wpPath"

    echo "Delete transients & WP Cache"
#    wp rocket clean --confirm --url=$new_site_url --path="$wpPath"
    wp transient delete --all --url=$new_site_url --path="$wpPath"
    wp cache flush --url=$new_site_url --path="$wpPath"
}

# echo "Flush Elementor Cache & CSS"
# wp elementor library sync --network --allow-root  --path="$wpPath"
# wp elementor flush_css --network --allow-root  --path="$wpPath"

# echo "Clear WPML cache"
# wp wpml clear-cache --network --allow-root  --path="$wpPath"

echo "Delete transients & WP Cache"

wp transient delete --all --network --allow-root  --path="$wpPath"
wp cache flush --network --allow-root  --path="$wpPath"

wp network meta set 1 siteurl "https://{$primarySiteURL}/"

Bonus: execută o comandă pe toate sub-site-urile

Uneori este nevoie să execuți o comandă în wp-cli pe toate site-urile. Faci un fișiere wpms.ps1, îl plasezi în PATH și pui în el:

 $allArgs = $PsBoundParameters.Values + $args

wp site list --field=url | ForEach-Object {
    echo "running wp $allArgs --url=$_"
    Invoke-Expression "wp $allArgs --url=$_"
}

Actualizarea plugin-urilor WordPress și versionarea acestora

Deși sunt adeptul ideii de a nu ține în Git fișierele care nu-mi aparțin: plugin-uri, fișierele din core etc, sunt unele situații în care este nevoie și de asta.

Prin urmare, nu am acordat prea multă atenție acestui mod de lucru: când e vorba de actualizări disponibile, facem actualizare la tot, trântim un commit cu ce plugin-uri s-au actualizat și aia e.

Doar că abordarea asta nu e cea mai potrivită, după cum am aflat recent: succes în a face un git bisect

Și mi-am dat seama că am la dispoziție o nouă jucărie: PowerShell!

function Wp-Plugins-Update() {
     $plugins = $(wp plugin list --update=available --field=name)

     foreach ($plugin in $plugins) {
        wp plugin update $plugin 
        & git add -Af "wp-content/plugins/$plugin"  --quiet
        & git commit -m "update plugin: $plugin"  --quiet
     }
}

Definești o funcție în %USERPROFILE%\Documents\PowerShell\profile.ps1 și… cam atât.

Execuți Wp-Plugins-Update (din PowerShell) în directorul în care ai instalat WP și îți va actualiza frumușel fiecare plugin. Apoi, pentru fiecare plugin va face un commit.

Cu puțină îndemânare, codul poate fi rescris pentru Bash.

WordPress menus are lost on server migration!

I had this issue for a very long time: a live server with a bunch of menus was allright. Trying to migrate the DB locally (for syncing), usually ended up in a mess, because menus were gone. No matter what tools I was usings, be it mysqldump, migrate db or anything else, menus were GONE! 

Worst part? This only happened only on some sites. On the bright side, I didn’t needed to make this sync too often so wasn’t that bad to do it manually, so I didn’t worry too much. 

Because I didn’t find any patterns, I assumed that is somehow related to a path conversion (given that I’m using a Windows machine).

I was wrong. Partially. Not really. But ignorance is bliss, right?

So fast forward few years. Today. I have this client with a huge site. HUGE. There are a bunch of menus. A lot of specific page menus. There are about 30 to 40 menus. Syncing those manually? Totally not an option! 

I started to dig into WP table structure. Dumping databases in various stages, then diff the whole db see what changes (as a side note, this approach looks pretty intriguing!).

So I managed to pinpoint the place where the menu locations are kept:

INSERT INTO `wp_options` VALUES (143,'theme_mods_MY_THEME_NAME','a:7:{i:0;b:0;s:18:\"nav_menu_locations\";a:4:{s|...

My very first though was: „wait a minute, I’m sure that serialization is messed up!”. Checking then double checking the serialized string and, sure enough, the serialization was correct. 

But then few lines above in the sql dump, I’ve noticed this:

INSERT INTO `wp_options` VALUES (110,'theme_mods_twentyseventeen','a:2

Yes! You see, the menus are stored as theme_mods and the theme is identified by… folder name!

And while on the remote server the theme folder was named MY_THEME_NAME, locally was… MY_THEME_NAME.org

Sure enough, If I ever used other theme modifications (custom styling, custom logo, widgets and so on), the pattern would had been more obvious.

TL;DR: use the same theme folder name on all servers.

Autoformatarea codului

Relativ recent am descoperit că pot face auto-formatarea codului la… commit. Da, știu, mind blowing 😀

Eu am nevoie de formatarea SCSS, JS, JSON și PHP. Folosesc întotdeauna composer câteva pachete node, deci am packages.json și composer.json mereu în proiect.  Prin urmare, folosim câteva pachete Node pentru a face treaba:

$ npm i --save-dev cross-env husky lint-staged prettier prettier-stylelint stylelint-scss

Husky instalează hooks de git necesare, lint-staged execută hooks pentru fișierele staged, cross-env permite setarea de variabile. Apoi, adăugăm în package.json:

{
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },

  "lint-staged": {
    "*.{js,jsx}": [ "prettier --write", "git add" ],
    "*.{json}": [ "prettier --write --parser=json", "git add" ],
    "*.{scss,sass}": [ "prettier-stylelint --quiet --write", "git add" ],
    "*.{php,php_cs}": [ "php -l", "cross-env PHP_CS_FIXER_FUTURE_MODE=1 php-cs-fixer fix --config=.php_cs", "git add" ]
  }
}

Apoi avem fișierele de config:

// .prettierrc
{
	"useTabs": true,
	"semi": true,
	"singleQuote": true,
	"trailingComma": "all",
	"bracketSpacing": true,
	"jsxBracketSameLine": true,
	"arrowParens": "always"
}

// .stylelintrc
{
    "plugins": [
        "stylelint-scss",
    ],
    "rules": {
        "indentation": 2,
        "string-quotes": "single",
        "color-no-invalid-hex": true,
        "function-parentheses-space-inside": "always",
        "media-feature-parentheses-space-inside": "always",
        "selector-pseudo-class-parentheses-space-inside": "always",
        "max-empty-lines": 2,
        "scss/at-function-parentheses-space-before": "always"
    }
}

Pentru formatarea codului PHP avem nevoie să instalăm un pachet global:

$ composer global require friendsofphp/php-cs-fixer
<?php 

//.php_cs

return PhpCsFixer\Config::create()
  ->setRiskyAllowed(true)
  ->setRules(
	[
		'@Symfony' => true,
		'@Symfony:risky' => true,
		'@PHP71Migration' => true,
		'braces' => true,
		'array_indentation' => true,
		'array_syntax' => ['syntax' => 'short'],
		'dir_constant' => true,
		'heredoc_to_nowdoc' => true,
		'linebreak_after_opening_tag' => true,
		'modernize_types_casting' => true,
		'multiline_whitespace_before_semicolons' => true,
		'no_unreachable_default_argument_value' => true,
		'no_useless_else' => true,
		'no_useless_return' => true,
		'ordered_class_elements' => true,
		'ordered_imports' => true,
		'phpdoc_add_missing_param_annotation' => ['only_untyped' => false],
		'phpdoc_order' => true,
		'doctrine_annotation_braces' => true,
		'doctrine_annotation_indentation' => true,
		'doctrine_annotation_spaces' => true,
		'psr4' => true,
		'no_php4_constructor' => true,
		'no_short_echo_tag' => true,
		'semicolon_after_instruction' => true,
		'align_multiline_comment' => true,
		'doctrine_annotation_array_assignment' => true,
		'general_phpdoc_annotation_remove' => ['annotations' => ['author', 'package']],
		'list_syntax' => ['syntax' => 'short'],
		'phpdoc_types_order' => ['null_adjustment' => 'always_last'],
		'single_line_comment_style' => true,
	]
  )
  ->setCacheFile(__DIR__.'/.php_cs.cache')
  ->setIndent("\t")
  ->setFinder(
	PhpCsFixer\Finder::create()
	  ->in(__DIR__)
  );

Și cam asta e. De fiecare dată când se face commit, autoformatarea se aplică pe fișierul modificat. Asta înseamnă și un timp mai mare de commit, dar până acum nu am observat un delay mai mare de 5-10 secunde.

Evident, regulile sunt ajustabile, fiecare formatter având propriile opțiuni.

Learning React: Atributul key={}

Mă tot gândeam: cum ar fi să scriu câte un articol despre lucrurile de care m-am lovit învățând React? Prin urmare, ăsta este primul post. Dacă or mai veni altele sau nu… vedem.

Atunci când randezi un element într-un loop (map, forEach), este recomandat să îi adaugi acelui element un atribut numit key. Acesta nu va fi trimis în props copiilor și este folosit exclusiv de React pentru diverse chestii (gen update).

Greșeala nr. 1: index key

Prima greșeală făcută de mine a fost să folosesc index-ul din loop pentru key. Ceva de genul:

lista.map((el, index) => <div key={index} />)

Rezultatul? Când actualizam props… unele elemente randate rămâneau neschimbate. Câteva ore mai târziu m-am prins și de ce: cheile ar trebui să fie unice! Prin urmare, am purces să fac….

Greșeala nr. 2: random key

A doua greșeală a fost să presupun că un key random este în regulă:

lista.map((el, index) => <div key={index + Date.now()} />)

Chiar dacă merge, vor fi probleme de performanță. De ce? Pentru că schimbarea unui singur element din listă va duce la re-randarea tuturor elementelor. Ouch!

Sigur, la 10-20 elemente nu ar trebui să fie probleme, dar la 1-200?

Greșeala nr. 3: prefix

La un moment dat am făcut ceva greșit și am rămas cu impresia că aceste key trebuie să fie unice în aplicație. Lucru cât se poate de fals, ele ar trebui să fie unice în părinte.

Dar până să mă prind de chestia asta, am pus mereu un prefix. Aveam aplicația plină de key={`users-${user.id}`}. Nu neapărat rău, dar inutil de verbose.

Cum ar trebui sa fie de fapt?

Dacă citeam cu atenție documentația, aflam că, în cazul ideal, te folosești de id-ul fiecărui element. Pentru că ghici ce? Cel mai probabil elementele alea vin din DB unde … au un ID! Abia în cazul în care nu au un ID te folosești de index!

Bonus: map, forEach, reduce, filter

Indiferent dacă ai sau nu de gând să folosești React, îți recomand două lucruri:

  1. Migrează pe ES6. Sunt o grămadă de lucruri care îți fac viața mai ușoară: spread operator, destructuring, arrow functions, let/const, module etc. Chiar dacă adaugă un pas de compilare, merită efortul.
  2. Chiar dacă te încăpățânezi să nu o faci, încearcă să înțelegi cum se folosesc toate funcțiile de manipulare arrays. Spre deosebire de PHP, folosirea funcțiilor native îți va aduce un plus de performanță. În plus, arată și mai bine 🙂 Iată două exemple:
const invoiceNumbers = invoices.map(invoice => invoice.number)

// sau

var invoiceNumbers = [];
for (var i = 0; i < invoices.length; i++) {
  invoiceNumbers.push(invoices[i].number)
}
const invoicesGreaterThan = invoices.filter(invoice => invoice.value > 1000)

// sau

var invoicesGreaterThan = [];

for (var i = 0; i < invoices.length; i++) {
  if (invoices[i].value > 1000) {
    invoicesGreaterThan.push(invoices[i])
  }
}
windows apple dropbox facebook twitter