Disable Rocket in PhpStorm EAP

Rocket gives me fast access to emoji on macOS, but the trigger I use, ::, is also common in PHP. I thought I had Rocket set to be disabled in PhpStorm, but it has been triggering when try do do things like Log::info('some statement');

After confirming that PhpStorm is on the list of Disabled Apps in Rocket, but Rocket was still triggering, I Googled how to disable Rocket for PhpStorm and found Rocket’s help instructions on IntelliJ IDEA. The same logic works for PhpStorm: Once PhpStorm is added to the list of disabled applications, double-click it in the list, and change it to “PhpStorm-EAP” and voila, Rocket stops triggering in PhpStorm.

FormRequest object Validation for Laravel Nova Resources

Laravel Nova gives us developers a relatively easy way to set up an administration panel for a website or app, but it’s been publicly available for less than a year and is only at version 1.3.1, so there are a few rough edges, like the inability to use FormRequest objects for validation when updating models, (called “Resources” in Nova parlance).

FormRequest objects are a way to detach form validation from specific controllers or routes so that the validation logic can be re-used, for example by using the same FormRequest object to validate updates made on a website or through an API. But they’re not supported by Nova so validation code needs to be re-written for Nova resources, or does it?

Comparing Laravel to Nova Validation

Validation rules in Nova are the same as the rest of Laravel, with the at least one tiny change in syntax, but instead of being passed around as one big list, each field has validation attached directly to it when defining the field. Here is the difference between rules in a FormRequest, (it’s the same whether using a FormRequest, Validator object, or in a controller), and rules for Nova fields:

Rules using a FormRequest

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StorePostRequest extends FormRequest
{
     // ....
	
     /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        // note all rules are returned as an array
    	return [
	        'title' => 'required|unique:posts|max:255',
	        'body' => 'required',
	    ];
    }

    // ....
}

Rules using Laravel Nova

namespace App\Nova;

use Laravel\Nova\Fields\ID;
use Illuminate\Http\Request;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Fields\Textarea;


class Post extends Resource
{

    // ...
	
    /**
     * Get the fields displayed by the resource.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function fields(Request $request)
    {

    	return [

    		// ...
    		
                // In Nova the rules are attached to fields, which themselves are an array.
    		Text::make('Title')->rules('required', 'unique:posts', 'max:255'),
    		Textarea::make('Body')->rules('required')

    		// ...

    	];
    }

    // ...
}

The “Laravel Way” to use a FormRequest for validation would be to type hint the $request passed to the fields method like this, (but it doesn’t work, apparently ):

namespace App\Nova;

use Illuminate\Http\Request;
use App\Http\Requests\StorePostRequest;


class Post extends Resource
{
    // ...

    // This type-hint produces a fatal error
    public function fields(StorePostRequest $request)
    {
    	return [
    		// ...
    	];
    }

    // ...
}

I’m actually not sure why the error is produced. The error I get says that Post@fields expects an instance of Illuminate\Http\Request, and that StorePostRequest isn’t one, but I am pretty sure it is an instance of Illuminate\Http\Request. Either way it doesn’t work, and that’s the expected behaviour, so a workaround is needed.

A Workaround

One way to only write validation rules once is to move them into a static method on the FormRequest object that has an optional $rule parameter to fetch one rule at a time if needed. Since Nova allows separate creationRules and updateRules for each field there should be three corresponding static methods on the FormRequest, and the non-static rules method can merge the rules for the rest of Laravel if needed.

Here’s the FormRequest class I’m using to provide validation rules for adding and editing users in an application I’m working on:


namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use App\Rules\Postalcode;


class StoreUserRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
	    // Users authorized to make the request are:
	    // - users updating themselves.
	    // - staff
	    // - guests creating a new user.


        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {

    	$rules = self::ruleGetter($this);


	    if(empty($this->user())) {
		    $rules = array_merge_recursive($rules, self::creationRuleGetter($this));
	    } else {
		    $rules = array_merge_recursive($rules, self::updateRuleGetter($this));
	    }

	    return $rules;
    }

    public static function ruleGetter( $request, $rule = null ) {

	    $rules = [
		    'email' => [
			    'required',
			    'string',
			    'email',
			    'max:255'
		    ],
		    'nickname' => [
			    'required',
			    'max:255'
		    ],
		    'job_title' => [
		    	'sometimes',
		        'max:255'
		    ],
		    'organization' => [
			    'sometimes',
			    'max:255'

		    ],
		    'state' => [
		    	'sometimes',
			    'nullable',
			    'in:AL,IS,TO,FS,TA,TE,S'
			],
		    'postalcode' => [
			    'sometimes',
			    new Postalcode
		    ],

		    'phone' => [
		    	'max:255'
		    ]
	    ];

	    if(!empty($rule)) {
	    	if(isset($rules[$rule])) {
			    return $rules[$rule];
		    }
	    	return '';
	    }
	    return $rules;
    }

    public static function creationRuleGetter( $request, $rule = null ) {
	    $rules = [
	    	'email' => [
	    		'unique:users,email'
		    ],

	        'nickname' => [
	        	'required',
		        'unique:users,nickname'
	        ],

		    'password' => [
			    'sometimes',
			    'required',
			    'string',
			    'min:8',
			    'confirmed'
		    ],
	    ];


	    if(!empty($rule)) {
		    if(isset($rules[$rule])) {
			    return $rules[$rule];
		    }
		    return '';
	    }
	    return $rules;

    }

    public static function updateRuleGetter( $request, $rule = null ) {


    	$rules = [
    		'email' =>  [
		        Rule::unique('users', 'email')->ignore($request->user()->id)
		    ],

		    'nickname' => [
			    Rule::unique('users', 'nickname')->ignore($request->user()->id)
		    ],

		    'password' => [
			    'sometimes',
			    'nullable',
			    'string',
			    'min:8',
			    'confirmed'
		    ],
	    ];

	    if(!empty($rule)) {
		    if(isset($rules[$rule])) {
			    return $rules[$rule];
		    }
		    return '';
	    }
	    return $rules;
    }

}

Then, in the Nova resource the rules can be attached to individual fields like this:

<?php

namespace App\Nova;

use Laravel\Nova\Fields\ID;
use Illuminate\Http\Request;
use Laravel\Nova\Fields\Text;
use Laravel\Nova\Fields\Boolean;
use Laravel\Nova\Fields\Gravatar;
use Laravel\Nova\Fields\Select;
use Laravel\Nova\Fields\Password;
use Laravel\Nova\Panel;
use Laravel\Nova\Fields\DateTime;
use Laravel\Nova\Fields\Textarea;
use Laravel\Nova\Fields\Place;
use Laravel\Nova\Http\Requests\NovaRequest;


use App\Http\Requests\StoreUserRequest;

class User extends Resource
{

    /**
     * The model the resource corresponds to.
     *
     * @var string
     */
    public static $model = 'App\\User';


    // ....


   /**
     * Get the fields displayed by the resource.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function fields(Request $request)
    {

        return [
            ID::make()->sortable(),

            Gravatar::make(),

	        Text::make('Email')
		        ->sortable()
			    ->withMeta( [
			    	'extraAttributes' => [
			    		'type' => 'email'
				    ]
			    ])
		        ->rules(StoreUserRequest::ruleGetter($request, 'email'))
		        ->creationRules(StoreUserRequest::creationRuleGetter($request, 'email'))
		        ->updateRules('unique:users,email,{{resourceId}}'),

		    Password::make('Password')
		            ->onlyOnForms()
		            ->rules(StoreUserRequest::ruleGetter($request, 'password')),

		    Text::make('Name', 'name')
		        ->sortable()
		        ->rules(StoreUserRequest::ruleGetter($request, 'name')),

		    Text::make('Preferred Name')
		        ->rules(StoreUserRequest::ruleGetter($request, 'preferred_name'))
		        ->hideFromIndex(),

		    Text::make('Nickname')
		        ->rules(StoreUserRequest::ruleGetter($request, 'nickname'))
		        ->creationRules(StoreUserRequest::creationRuleGetter($request, 'nickname'))
		        ->updateRules('unique:users,nickname,{{resourceId}}')
		        ->hideFromIndex(),

		    Text::make('Job Title')
		        ->rules(StoreUserRequest::ruleGetter($request, 'job_title'))
		        ->hideFromIndex(),

		    Text::make('Company Name', 'organization')
		        ->rules(StoreUserRequest::ruleGetter($request, 'organization'))
		        ->hideFromIndex(),

		    Place::make('Address')
		        ->rules(StoreUserRequest::ruleGetter($request, 'address'))
			    ->countries(['US', 'CA'])
			    ->secondAddressLine('address2')
			    ->city('city')
			    ->state(['stateprov'])
			    ->postalCode('postalcode')
		        ->hideFromIndex(),

		    Text::make('Address Line 2', 'address2')
		        ->rules(StoreUserRequest::ruleGetter($request, 'address2'))
			    ->hideFromIndex(),

		    Text::make('City')
		        ->rules(StoreUserRequest::ruleGetter($request, 'city'))
		        ->hideFromIndex(),

		    Select::make('State or Province', 'stateprov')
			    ->options(get_stateprovs())
			    ->rules(StoreUserRequest::ruleGetter($request, 'stateprov'))
			    ->hideFromIndex(),

		    Text::make('ZIP or Postal Code', 'postalcode')
		        ->rules(StoreUserRequest::ruleGetter($request, 'postalcode'))
			    ->hideFromIndex(),

		    Text::make('Phone')
		        ->rules(StoreUserRequest::ruleGetter($request, 'phone'))
			    ->withMeta(['extraAttributes' => [
			    	'type' => 'tel'
			    ]])
		        ->hideFromIndex(),

        ];
    }

    // ....

}

Instead of writing out actual strings of rules for the Nova resource, the StoreUserRequest::ruleGetter method supplies the rule, allowing one canonical repository of rules for use with Users.

An Exception

There is an exception to the beauty: Enforcing unique values in a table, but ignoring a certain user ID, (for example when updating a user profile the user should not see a “This E-mail is already in use” error if the E-mail isn’t changed). Nova handles it like this:

// Simple and elegant 
Text::make('Email')
->rules('unique:users,email,{{resourceId}}')

Non-Nova Laravel handles it like this:

Rule::unique('users')->ignore($user->id, 'user_id')

But when declaring Nova fields a $user object, or the ID of the model that is being updated, is not available so this does not work. I have worked around the problem by writing out these few cases in the updateRules() method of the Nova field, as in this excerpt from above:

// Excerpt from full Nova Resource definition.
// Most Rules use one of the ruleGetter methods on StoreUserRequest
// but updateRules are just typed out to take advantage of Nova syntax.
Text::make('Email')
->rules(StoreUserRequest::ruleGetter($request, 'email'))
->creationRules(StoreUserRequest::creationRuleGetter($request, 'email'))
->updateRules('unique:users,email,{{resourceId}}'),

Other Possibilities

Ideally it would be possible to have the FormRequest object do the validation itself, without having to pass individual rules to Nova fields. Another possibility is to turn the Request that’s passed to a Resource’s fields method into a FormRequest, or to instantiate a completely new FormRequest object, and let the FormRequest object handle validation. Unfortunately I don’t know where we would do this, (the fields method doesn’t seem right), and I don’t know if the redirects and error messages generated by FormRequest validation would work properly with Nova. I would love to hear if someone can make this work.

Conclusion

I sincerely hope that Nova will soon allow automatic validation with FormRequest objects, but until that happens it is possible to keep code almost DRY and feed individual rules to Nova fields with ruleGetter methods on our FormRequests.

Vanilla Speed

Me: I wonder how to make this cool new HTML5 feature interact with Javascript properly.

Googles…

Stack Overflow Answer #1: Use this great jQuery function!

Stack Overflow Answer #2: Underscore.js is better, us it! You only need 1/2 a character of code.

Smacks desk in frustration.

I’m working on a major overhaul of an existing website that focuses on speed and a great mobile experience that has never used Javascript libraries, (it was originally written in 2007 before they were a thing), and we’re not changing that. For this website a library is a bunch of extra code to download, execute, and take up memory, which adds extra function calls between the code I write and the things that happen in the browser – more CPU cycles, more battery drain, more waiting, and no benefit to our users and customers.

If we were using jQuery I wouldn’t have spent some time looking up the differences in event creation between browsers, and I might not have spent the morning figuring out why a datalist wasn’t appearing properly in Firefox, or maybe I would have had to do that bit of debugging anyway. One thing that doesn’t help me move the project along at all is other people’s over-reliance on Javascript libraries that is so prevalent. In 2007 when I had a problem I could Google it, or post to a forum, and find an answer, (assuming it had been done before), today I experience the sequence of events at the top of this post several times every day. This is why the trio of blog posts last week, (Marco Arment’s post about PPK’s post about John Gruber’s take on Facebook Instant), have really resonated with me. PPK is right, we need to stop relying on libraries, whether it’s jQuery, underscore, or whatever. There are times when a library is the right tool, but with modern Javascript APIs, HTML5, and CSS3, and good browser support for all but the absolute bleeding-edge.

To use a Javascript library is to externalize the cost of development onto your customers and users.

Despite my frustration with the “use some library” responses on Stack Overflow questions using plain vanilla JavaScript has turned out to be an enjoyable learning experience. I’ve been able to make everything work, (so far), with way less code than was needed in 2007. Of course, if your browser can’t handle the project’s Javascript requirements the JS is not loaded at all and you get the plain HTML experience, which isn’t so bad either.

For more on choosing not to burden your users and customers with unneeded slowness, see the ALA article Choosing Vanilla Javascript, which is 15 months old now, and things have just gotten better since it was published!

Apache/cPanel Log Analysis using MySQL

Every once in a while I need to analyze log files from a web server. Sometimes people ask my what is sucking up all the bandwidth on their hosting plan, other times bots are driving them crazy so we try to identify easy ones to block. Sometimes we want to know how many times a certain URL, or group of URLs was loaded. The logs I work with usually come from LAMP servers running cPanel on a shared host but the techniques below are adaptable to any format by adjusting the import query.

Tools:

I usually use Sequel Pro to run queries. Even when writing the queries myself I prefer a GUI to the command line. It is much easier to inspect tables visually and my query history is saved. That said, we’re working with plain SQL here, (maybe with a bit of MySQL flavour), so any MySQL client will work.

You also need a MySQL server to process the queries. I use the same one as I use for development on my laptop, if you use MAMP, WAMP, or any other AMP you should have access to a local MySQL server.

Part 1: Setup

Before analyzing the data it must be imported, and before importing data there needs to be a table to put it in. Here is the CREATE TABLE statement I use:

 CREATE TABLE `analysis_job` (
`ip` varchar(16) DEFAULT NULL,
`clientid` varchar(128) DEFAULT NULL,
`userid` varchar(128) DEFAULT NULL,
`timestamp` varchar(128) DEFAULT NULL,
`offset` varchar(128) DEFAULT NULL,
`request` varchar(250) DEFAULT NULL,
`status` int(128) DEFAULT '0',
`size` int(128) DEFAULT '0',
`referrer` varchar(250) DEFAULT NULL,
`useragent` varchar(250) DEFAULT NULL,
`ts` datetime DEFAULT NULL,
`uts` bigint(10) DEFAULT NULL
) ENGINE=MyISAM DEFAULT CHARSET=utf8;

A few of these columns may need more explanation:

  • clientid: something Apache creates, almost alway blank
  • userid: something else Apache creates almost always blank
  • request: the contents of the HTTP request, includes the HTTP method, (usually GET or POST), the URL, and the HTTP version
  • status: the HTTP Status code the server sent
  • size: the size of the response. I believe this is in bytes.
  • ts: A timestamp with better formatting than the timestamp column to make it easier to do date operations, (we’ll compute this after importing the data)
  • uts: A Unix Timestamp representation of the ts column, handy for sorting.

Each line of the log file is a single HTTP request. The import statement tells the MySQL server that the fields are enclosed by double quotes and delimited by spaces, from left to right. This means that the timestamp gets broken into two columns, hence the timestamp and offset columns in the CREATE TABLE statement above.

And here’s a sample log entry for a request from Bingbot, (this is a single line from the log file, it is wrapped to multiple lines here):

127.0.0.1 - - [30/Nov/2014:04:00:45 -0800] "GET /directory/page.html HTTP/1.1" 200 9499 "-" "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"

For this request the column values are:

ip:
127.0.0.1
clientid:
userid:
timestamp:
[30/Nov/2014:04:00:45
offset:
-0800]
request:
GET /directory/page.html HTTP/1.1
status:
200
size:
9499
referrer:
useragent:
Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)

The WHM/cPanel based servers that I have encountered always use this log format, but if the server you are working with is different you will have to modify the CREATE TABLE statement to match it.

Part 2: Importing Data

With the table set up it’s fairly quick & easy to import the data:

#Import data
LOAD DATA LOCAL INFILE '/path/to/logfile/logfile' 
INTO TABLE analysis_job
FIELDS TERMINATED BY ' ' ENCLOSED BY '"' LINES TERMINATED BY "\n";

This loads everything into the table. Now we have to extract an actual date from the timestamp column and turn it into a datetime for the ts column using MySQL’s STR_TO_DATE() function:

# to make the string into an actual datetime
UPDATE analysis_job SET ts = STR_TO_DATE(timestamp, '[%d/%b/%Y:%H:%i:%s');

Now there’s a small problem to solve: If the system timezone setting on the server is different from the timezone setting in MySQL on whatever computer you’re using as a database server, and if you want to be able to ask “what happened today in my timezone?” we need to shift the content of the ts column to match your timezone. You can see the UTC offset of the server by looking at the offset column. In my case the original server is on Pacific Time, and I’m on Eastern Time, so we need to add 3 hours to ts:

# adjust timezone
UPDATE analysis_job SET ts = DATE_ADD(ts, INTERVAL 3 HOUR);

If you want to have the Unix Timestamp version of ts available to you then you need to set it:

# Set unix timestamp column
UPDATE analysis_job SET uts = UNIX_TIMESTAMP( ts );

Part 3: Analysis

Here’s the fun part. If you’re good with the ORDER BY and GROUP BY clauses of SQL, and their corresponding functions, you can find all sorts of interesting stuff. Here are some examples that I’ve used:

Find the total bandwidth used during the time your log file spans:

SELECT SUM(size) as totalsize, (SUM(size) / 1048576) as MB FROM analysis_job;

Or for the last week, (change the WHERE clause for different timeframes):

SELECT SUM(size) as totalsize, (SUM(size) / 1048576) as MB FROM analysis_job WHERE ts > DATE_SUB( NOW(), INTERVAL 1 WEEK );

Find the requests, (and therefore files), that use the most bandwidth:

SELECT request, SUM(size) as totalsize, (SUM(size) / 1048576) as MB FROM analysis_job GROUP BY request ORDER BY totalsize Desc;

Do you suspect there’s one computer out there hammering your site? Find the IP that’s using the most bandwidth:

SELECT ip, SUM(size) as totalsize, (SUM(size) / 1048576) as MB FROM analysis_job GROUP BY ip ORDER BY totalsize Desc;

Or the User-Agent:

SELECT useragent, SUM(size) as totalsize, (SUM(size) / 1048576) as MB FROM analysis_job GROUP BY useragent ORDER BY totalsize Desc;

Who is hammering you with the most requests?

SELECT ip, COUNT(*) as requests FROM analysis_job GROUP BY ip ORDER BY requests Desc;

Again, group by User-Agent to find the program, (assuming the User-Agent isn’t spoofed):

SELECT user agent, COUNT(*) as requests FROM analysis_job GROUP BY user agent ORDER BY requests Desc;

Any of these can be restricted by date, like we did with bandwidth above.

You can also zoom in on one User-Agent, for example:

SELECT * FROM analysis_job WHERE useragent='Mozilla/4.0 (compatible; MSIE 7.0b; Windows NT 6.0)';

Or certain file types:

SELECT * FROM analysis_job WHERE request LIKE '%.css%'; # CSS
SELECT * FROM analysis_job WHERE request LIKE '%.html%'; # HTML

Mix it up for detailed analysis. This finds the IPs that have sent the most hits in the past 5 hours that don’t declare themselves as bots and are accessing normal parts of the website, (not cPanel, stats packages, robots.txt, etc):

# Non-bot IPs that have sent the most hits in the last 5 hours
SELECT COUNT(*) AS cnt, ip FROM analysis_job 
WHERE
ts > DATE_SUB(NOW(), INTERVAL 5 HOUR) 
AND useragent NOT LIKE '%bot%' 
AND useragent NOT LIKE '%spider%' 
AND useragent NOT LIKE 'Mediapartners%' 
AND request NOT LIKE '%/api/1/%' 
AND request NOT LIKE '%/media/%'
AND request NOT LIKE '%/pub/%'
AND request NOT LIKE '%cpanel/%'
AND request NOT LIKE '%mint/%'
AND request NOT LIKE '%/robots.txt%'
GROUP BY ip
ORDER BY cnt Desc;

Part 4: Other Considerations

While this data can help find interesting traffic patterns and trends it is important to remember that IP addresses can be spoofed or proxied and User-Agents can be set to whatever a client wants so those values cannot be entirely trusted.

In addition, if you use CloudFlare, the IP address will almost always be the IP of a CloudFlare server unless your host has mod_cloudflare, or some equivalent, installed.

Even with these caveats server logs are an untapped information mine, and having a chunk of time in a database table makes it much easier to dig in that mine.

Adding to a WordPress page’s URL without changing the URL of its Child Pages.

I was recently asked to add an extra path component to the URL of a WordPress page for SEO reasons. It took some diving into the internals of WordPress. Here’s the setup:

  • WordPress is installed in the site root, at example.com
  • We are using a static homepage
  • The company blog is at example.com/blog/
  • I was asked to make the blog URL be example.com/blog/some-extra-keywords/ without changing the URL of the current and future blog posts, which are currently example.com/blog/post-name/

There are 3 things required to make this work:

  1. A filter to tell WordPress to process requests for /blog/some-extra-keywords/ as if they are for /blog/
  2. A filter to change permalinks from /blog/ to /blog/some-extra-keywords/ (Optional, but recommended).
  3. A redirect from /blog/ to /blog/some-extra-keywords/ (Also optional, but also recommended).

Treat /blog/some-extra-keywords/ requests like /blog/

We don’t want to change the URL of the blog page in WordPress’s Edit Page screen because that will change the URL of past and future all blog posts. We need to tell WordPress that a request for /blog/some-extra-keywords/ is a request for /blog/. This is done by adding a filter to WordPress’s request filter hook:

/**
 * Make the URL /blog/some-extra-keywords/ respond as /blog/ 
 * would.
 * @param  array $request The request array that WP generates.
 * @return array          The modified request array.
 */
function jb_filter_blog_url_request( $request ) {

    if ( isset( $request['name'] ) && 'some-extra-keywords' == $request['name'] ) {
                // it's a page we're requesting. If you're doing this to something other
                // than a page, try setting $request['name'], and don't unset it 2 lines
                // later
        $request['pagename'] = 'blog';
        unset( $request['name'] );
    }

    return $request;
}
add_filter( 'request', 'jb_filter_blog_url_request' );

Since WordPress identifies pieces of content by the last part of the URL, (the “name”), that’s all we have to change. In this case, because the blog is on a page, we had to set $request['pagename'].

Note: This is optional, but recommended. If you don’t do this you must do the next item, (redirect /blog/ to /blog/some-extra-keywords/), but you get the most SEO benefit if you do both.

With #1 it’s time to change links to the blog page to point to the new URL. The old URL still works, and you’ll be redirecting it in Step 3, but modifying the links cuts out a round-trip to the server, making your site faster, and it means that anytime a spider crawls the site it will find the correct URL.

Again, we create a filter function and hook it up. Because I’m doing this all to a page, I’m hooking on to the page_link filter, but the function also works with the post_link filter. In fact, I originally used the post_link filter, which doesn’t work for pages, and spent a while banging my head against a wall trying to figure out why it didn’t work.

By hooking into page_link or post_link you’ll modify the URL everywhere it is generated with get_permalink(), which is almost everywhere. Menus, lists of pages, and even the XML Sitemap that Yoast’s WordPress SEO plugin makes will be have the new URL. If you have typed the URL somewhere this filter will not change it, but that’s why you’ll set up a redirect in Step 3.

The code:

/**
 * Modifies the blog URL when it's requested using get_permalink(). 
 * 
 * Note that this filter is set up to work on both posts & pages, and the 
 * post_link and page_link filters don't pass quite the same parameters:
 * - $post is an post object for post_link and a post ID for page_link, so we
 *   get the post object if $post is numeric
 * - $dontuse is completely different between the two filters, but not needed,
 *   so we ignore it.
 * 
 * @param  string $url       The URL to be filtered.
 * @param  mixed  $post      The post ID or post object that corresponds to $url
 * @param  mixed  $leavename Differs depending on filter. Ignore.
 * @return string            The possibly-modified URL.
 */
function jb_modify_blog_url( $url, $post, $leavename ) {
    $true = false;
 
        // don't do it in the admin, I'm afraid the modified URL will get
        // added to the URL slug field on the Edit Page screen, and get
        // permanently added, with another copy of it being added every time
        // the page is saved.
    if( ! is_admin() ) {

        if ( is_numeric( $post ) ) {
            $post = get_post( $post );
        }


        if( 'blog' == $post->post_name ) {
            $url .= 'some-extra-keywords/';
        }
    }
    return $url;
}
add_filter( 'page_link', 'jb_modify_blog_url', 10, 3 );

The magic is in the third if condition: If the page name is ‘blog’ add the extra keywords to the URL.

Redirect from /blog/ to /blog/some-extra-keywords/

Note: This is optional, as well, but if you don’t do it the blog homepage will be available at both /blog and /blog/some-extra-keywords/ which could lead to a duplicate content penalty from Google.

We’re going to do a 301 Redirect to tell search engines that the blog is now at /blog/some-extra-keywords/. I’m going to do this in a .htaccess file because that way the web server doesn’t have to start PHP or WordPress.

If you’ve already got a rewrite section in your .htaccess file, add the following line to it:

RewriteRule ^blog/?$ /blog/some-extra-keywords/ [R=301,L]

This will redirect both /blog/ and /blog, (the first ? makes the slash right before it optional), to /blog/some-extra-keywords/. If you don’t have a rewrite section, add one:

<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /

RewriteRule ^blog/?$ /blog/whole-house-fan-energy-saving-tips/ [R=301,L]

</IfModule>

And that’s it.